Merge branch 'develop' into feature/60-implement-drift-database

# Conflicts:
#	lib/views/active_game_view.dart
#	lib/views/create_game_view.dart
#	lib/views/graph_view.dart
#	lib/views/main_menu_view.dart
#	lib/views/round_view.dart
#	pubspec.yaml
This commit is contained in:
2025-08-21 19:19:32 +02:00
54 changed files with 11273 additions and 2485 deletions

40
.github/workflows/pull_request.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: Pull Request Pipeline
on:
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set Up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.32.1'
channel: 'stable'
- name: Check Formatting
run: flutter analyze
test:
runs-on: ubuntu-latest
if: always()
needs: lint
steps:
- uses: actions/checkout@v4
- name: Set Up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.32.1'
channel: 'stable'
- name: Get dependencies
run: flutter pub get
- name: Run Tests
run: flutter test

View File

@@ -1,38 +1,81 @@
name: Flutter
name: Push Pipeline
on:
push:
branches:
- "develop"
- "main"
pull_request:
- "develop"
- "main"
jobs:
lint:
runs-on: macos-latest
generate_licenses:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkout code
uses: actions/checkout@v2
- name: Set up Flutter
uses: subosito/flutter-action@v1.5.3
with:
flutter-version: '3.32.1'
channel: "stable"
- name: Get dependencies
run: flutter pub get
- name: Generate oss_licenses.dart
run: flutter pub run flutter_oss_licenses:generate -o lib/presentation/views/about/licenses/oss_licenses.dart
- name: Escape dollar signs in licenses
run: |
sed -i 's/\$/\\$/g' lib/presentation/views/about/licenses/oss_licenses.dart
- name: Check for changes
id: check_changes
run: |
if [[ $(git status --porcelain) ]]; then
echo "changes_detected=true" >> $GITHUB_OUTPUT
else
echo "changes_detected=false" >> $GITHUB_OUTPUT
fi
- name: Commit Changes
if: steps.check_changes.outputs.changes_detected == 'true'
run: |
git config --global user.name "GitHub Actions"
git config --global user.email "actions@github.com"
git add .
git commit -m "Actions: Licenses updated [skip ci]"
git push
lint:
runs-on: ubuntu-latest
needs: generate_licenses
if: always()
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set Up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.29.2'
flutter-version: '3.32.1'
channel: 'stable'
- name: Check Formatting
run: flutter analyze
format:
runs-on: macos-latest
runs-on: ubuntu-latest
needs: lint
if: ${{ failure() && needs.lint.result == 'failure' && github.event_name == 'push' && github.ref == 'refs/heads/develop'}}
if: ${{ failure() && needs.lint.result == 'failure'}}
steps:
- uses: actions/checkout@v4
- name: Set Up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.29.2'
flutter-version: '3.32.1'
channel: 'stable'
- name: Get & upgrade dependencies
@@ -64,7 +107,7 @@ jobs:
git push
test:
runs-on: macos-latest
runs-on: ubuntu-latest
if: always()
needs: [lint, format]
@@ -74,15 +117,18 @@ jobs:
- name: Set Up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.29.2'
flutter-version: '3.32.1'
channel: 'stable'
- name: Get dependencies
run: flutter pub get
- name: Run Tests
run: flutter test
build:
runs-on: macos-latest
runs-on: ubuntu-latest
if: false # skips job
needs: [lint, format, test]
@@ -92,10 +138,10 @@ jobs:
- name: Set Up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.29.2'
flutter-version: '3.32.1'
channel: 'stable'
- name: Install dependencies
- name: Get dependencies
run: flutter pub get
- name: Analyze project source

View File

@@ -1,6 +1,6 @@
# CABO Counter
![Version](https://img.shields.io/badge/Version-0.3.0-orange)
![Version](https://img.shields.io/badge/Version-0.5.8-orange)
![Flutter](https://img.shields.io/badge/Flutter-3.32.1-blue?logo=flutter)
![Dart](https://img.shields.io/badge/Dart-3.8.1-blue?logo=dart)
![iOS](https://img.shields.io/badge/iOS-18.5-white?logo=apple)
@@ -12,25 +12,29 @@ A mobile score tracker for the card game Cabo, helping players effortlessly mana
## 📱 Description
Cabo Counter is an intuitive Flutter-based mobile application designed to enhance your CABO card game experience. It eliminates manual scorekeeping by automatically calculating points per round.
Cabo Counter is an intuitive Flutter-based mobile application designed to enhance your CABO card game experience. It eliminates manual scorekeeping by automatically calculating points per round.
## ✨ Features
- 🆕 Create new games with customizable rules
- 👥 Support for 2-5 players
- ⚖️ Two game modes:
- **100 Points Mode** (Standard)
- **Infinite Mode** (Casual play)
- **Point Limit Mode**: Play until a certain point limit is reached
- **Unlimited Mode**: Play without an limit and end the round at any point
- 🔢 Automatic score calculation with:
- Falsly calling Cabo
- Exact 100-point bonus (score halving)
- Kamikaze rule handling
- Exact 100-point bonus (score halving)
- 📊 Round history tracking
- 📊 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
### Prerequisites
- Flutter 3.24.5+
- Dart 3.5.4+
- Flutter 3.32.1+
- Dart 3.8.1+
### Installation
@@ -43,18 +47,22 @@ flutter run
## 🎮 Usage
1. **Start New Game**
- Choose game mode (100 Points or Infinite)
1. **Start a new game**
- Click the "+"-Button
- Choose a game title and a game mode
- Add 2-5 players
2. **Gameplay**
- Track rounds with automatic scoring
- Handle special rules (Kamikaze, exact 100 points)
- View real-time standings
- Open the first round
- Choose the player who called Cabo
- 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**
- Automatic winner detection
- Penalty point calculation
3. **Statistics**
- View the progress graph for the game
- Get a detailed table overview for every points made or lost
- Game-over detection (100 Points mode)
## 🃏 Key Rules Overview
@@ -67,7 +75,8 @@ flutter run
- Exact 100 points: Score halved
### 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
## 🤝 Contributing

View File

@@ -11,6 +11,3 @@ linter:
prefer_const_literals_to_create_immutables: true
unnecessary_const: true
lines_longer_than_80_chars: false
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

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

@@ -1,5 +1,6 @@
arb-dir: lib/l10n
arb-dir: lib/l10n/arb
template-arb-file: app_de.arb
untranslated-messages-file: lib/l10n/untranslated_messages.json
untranslated-messages-file: lib/l10n/arb/untranslated_messages.json
nullable-getter: false
output-localization-file: app_localizations.dart
output-localization-file: app_localizations.dart
output-dir: lib/l10n/generated

57
lib/core/constants.dart Normal file
View File

@@ -0,0 +1,57 @@
import 'package:rate_my_app/rate_my_app.dart';
/// A utility class that holds constant values and configuration settings
/// used throughout the application, such as external links, email addresses,
/// and timing parameters for UI elements.
///
/// This class also provides an instance of [RateMyApp] for managing
/// in-app rating prompts.
class Constants {
/// Indicates the current development phase of the app
static const String appDevPhase = 'Beta';
/// Links to various social media profiles and resources related to the app.
/// URL to my Instagram profile
static const String kInstagramLink = 'https://instagram.felixkirchner.de';
/// URL to my GitHub profile
static const String kGithubLink = 'https://github.felixkirchner.de';
/// URL to the GitHub issues page for reporting bugs or requesting features.
static const String kGithubIssuesLink =
'https://cabo-counter-issues.felixkirchner.de';
/// URL to the GitHub wiki for additional documentation and guides.
static const String kGithubWikiLink =
'https://cabo-counter-wiki.felixkirchner.de';
/// Official email address for user inquiries and support.
static const String kEmail = 'cabocounter@felixkirchner.de';
/// URL to the app's privacy policy page.
static const String kPrivacyPolicyLink =
'https://cabo-counter-datenschutz.felixkirchner.de';
/// URL to the app's imprint page, containing legal information.
static const String kLegalLink = 'https://impressum.felixkirchner.de';
/// Instance of [RateMyApp] configured to prompt users for app store ratings.
static RateMyApp rateMyApp = RateMyApp(
appStoreIdentifier: '6747105718',
minDays: 15,
remindDays: 45,
minLaunches: 15,
remindLaunches: 40);
/// Delay in milliseconds before a pop-up appears.
static const int kPopUpDelay = 300;
/// Delay in milliseconds before the round view appears after the previous one is closed.
static const int kRoundViewDelay = 600;
/// Duration in milliseconds for the fade-in animation of texts.
static const int kFadeInDuration = 300;
/// Duration in milliseconds for the keyboard to fully disappear.
static const int kKeyboardDelay = 300;
}

View File

@@ -0,0 +1,69 @@
import 'package:flutter/cupertino.dart';
class CustomTheme {
/// Main Theme of the App
/// Primary white color mainly used for text
static Color white = CupertinoColors.white;
/// Red color, typically used for destructive actions or error states
static Color red = CupertinoColors.destructiveRed;
/// Primary color of the app, used for buttons, highlights, and interactive elements
static Color primaryColor = CupertinoColors.systemGreen;
/// Background color for the main app scaffold and views
static Color backgroundColor = const Color(0xFF101010);
/// Background color for main UI elements like cards or containers.
static Color mainElementBackgroundColor = CupertinoColors.darkBackgroundGray;
/// Background color for player tiles in lists.
static Color playerTileColor = CupertinoColors.secondaryLabel;
/// Background color for buttons and interactive controls.
static Color buttonBackgroundColor = const Color(0xFF202020);
/// Color used to highlight the kamikaze button and players
static Color kamikazeColor = CupertinoColors.systemYellow;
// Line Colors for GraphView
static const Color graphColor1 = Color(0xFFF44336);
static const Color graphColor2 = Color(0xFF2196F3);
static const Color graphColor3 = Color(0xFFFFA726);
static const Color graphColor4 = Color(0xFF9C27B0);
static final Color graphColor5 = primaryColor;
// Colors for PointsView
/// Color used to indicate a loss of points in the UI.
static Color pointLossColor = primaryColor;
/// Color used to indicate a gain of points in the UI.
static const Color pointGainColor = Color(0xFFF44336);
// Text Styles
/// Text style for mode titles, typically used in headers or section titles.
static TextStyle modeTitle = TextStyle(
color: primaryColor,
fontSize: 20,
fontWeight: FontWeight.bold,
);
/// Default text style for mode descriptions.
static const TextStyle modeDescription = TextStyle(
fontSize: 16,
);
/// Text style for titles of sections of [CupertinoListTile].
static TextStyle rowTitle = TextStyle(
fontSize: 20,
color: primaryColor,
fontWeight: FontWeight.bold,
);
/// Text style for round titles, used for prominent display of the round title
static TextStyle roundTitle = TextStyle(
fontSize: 60,
color: white,
fontWeight: FontWeight.bold,
);
}

View File

@@ -1,5 +1,6 @@
import 'package:cabo_counter/data/models/game_session.dart';
import 'package:cabo_counter/services/local_storage_service.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
class GameManager extends ChangeNotifier {
@@ -10,32 +11,68 @@ class GameManager extends ChangeNotifier {
/// 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.
/// Returns the index of the newly added session in the sorted list.
Future<int> addGameSession(GameSession session) async {
int addGameSession(GameSession session) {
session.addListener(() {
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.');
LocalStorageService.saveGameSessions();
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.
/// 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;
gameList[index].setWinner();
notifyListeners();
LocalStorageService.saveGameSessions();
}
}
final gameManager = GameManager();

View File

@@ -1,5 +1,6 @@
import 'package:cabo_counter/data/models/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 {
final String id;
final DateTime createdAt;
final String gameTitle;
final List<String> players;
@@ -25,6 +27,7 @@ class GameSession extends ChangeNotifier {
List<Round> roundList = [];
GameSession({
required this.id,
required this.createdAt,
required this.gameTitle,
required this.players,
@@ -37,13 +40,14 @@ class GameSession extends ChangeNotifier {
@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 +63,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,15 +77,6 @@ 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() {
int length = 0;
for (String player in players) {
length += player.length;
}
return length;
}
/// Assigns 50 points to all players except the kamikaze player.
/// [kamikazePlayerIndex] is the index of the kamikaze player.
void applyKamikaze(int roundNum, int kamikazePlayerIndex) {
@@ -227,7 +223,7 @@ class GameSession extends ChangeNotifier {
/// This method updates the points of each player after a round.
/// It first uses the _sumPoints() method to calculate the total points of each player.
/// Then, it checks if any player has reached 100 points. If so, it marks
/// Then, it checks if any player has reached 100 points. If so, saves their indices and marks
/// that player as having reached 100 points in that corresponding [Round] object.
/// If the game has the point limit activated, it first applies the
/// _subtractPointsForReachingHundred() method to subtract 50 points
@@ -235,21 +231,31 @@ class GameSession extends ChangeNotifier {
/// It then checks if any player has exceeded 100 points. If so, it sets
/// isGameFinished to true and calls the _setWinner() method to determine
/// the winner.
Future<void> updatePoints() async {
/// It returns a list of players indices who reached 100 points (bonus player)
/// in the current round for the [RoundView] to show a popup
List<int> updatePoints() {
List<int> bonusPlayers = [];
_sumPoints();
if (isPointsLimitEnabled) {
_checkHundredPointsReached();
bonusPlayers = _checkHundredPointsReached();
bool limitExceeded = false;
for (int i = 0; i < playerScores.length; i++) {
if (playerScores[i] > pointLimit) {
isGameFinished = true;
limitExceeded = true;
print('${players[i]} hat die 100 Punkte ueberschritten, '
'deswegen wurde das Spiel beendet');
_setWinner();
setWinner();
}
}
if (!limitExceeded) {
isGameFinished = false;
}
}
notifyListeners();
return bonusPlayers;
}
@visibleForTesting
@@ -270,30 +276,37 @@ class GameSession extends ChangeNotifier {
/// Checks if a player has reached 100 points in the current round.
/// If so, it updates the [scoreUpdate] List by subtracting 50 points from
/// the corresponding round update.
void _checkHundredPointsReached() {
List<int> _checkHundredPointsReached() {
List<int> bonusPlayers = [];
for (int i = 0; i < players.length; i++) {
if (playerScores[i] == pointLimit) {
bonusPlayers.add(i);
print('${players[i]} hat genau 100 Punkte erreicht und bekommt '
'deswegen 50 Punkte abgezogen');
roundList[roundNumber - 1].scoreUpdates[i] -= 50;
'deswegen ${(pointLimit / 2).round()} Punkte abgezogen');
roundList[roundNumber - 1].scoreUpdates[i] -= (pointLimit / 2).round();
}
}
_sumPoints();
return bonusPlayers;
}
/// Determines the winner of the game session.
/// It iterates through the player scores and finds the player
/// with the lowest score.
void _setWinner() {
int score = playerScores[0];
String lowestPlayer = players[0];
void setWinner() {
int minScore = playerScores.reduce((a, b) => a < b ? a : b);
List<String> lowestPlayers = [];
for (int i = 0; i < players.length; i++) {
if (playerScores[i] < score) {
score = playerScores[i];
lowestPlayer = players[i];
if (playerScores[i] == minScore) {
lowestPlayers.add(players[i]);
}
}
winner = lowestPlayer;
if (lowestPlayers.length > 1) {
winner =
'${lowestPlayers.sublist(0, lowestPlayers.length - 1).join(', ')} & ${lowestPlayers.last}';
} else {
winner = lowestPlayers.first;
}
notifyListeners();
}

View File

@@ -1,93 +0,0 @@
{
"@@locale": "de",
"app_name": "Cabo Counter",
"round": "Runde",
"rounds": "Runden",
"mode": "Modus",
"points": "Punkte",
"unlimited": "Unbegrenzt",
"delete": "Löschen",
"cancel": "Abbrechen",
"game": "Spiel",
"ok": "OK",
"player": "Spieler:in",
"players": "Spieler:innen",
"name": "Name",
"back": "Zurück",
"home": "Home",
"about": "Über",
"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": {
"placeholders": {
"gameTitle": {
"type": "String"
}
}
},
"overview": "Übersicht",
"new_game": "Neues Spiel",
"game_title": "Titel des Spiels",
"select_mode": "Wähle einen Modus",
"add_player": "Spieler:in hinzufügen",
"create_game": "Spiel erstellen",
"max_players_title": "Maximale Anzahl erreicht",
"max_players_message": "Es können maximal 5 Spieler:innen hinzugefügt werden.",
"no_gameTitle_title": "Kein Titel",
"no_gameTitle_message": "Es muss ein Titel für das Spiel eingegeben werden.",
"no_mode_title": "Kein Modus",
"no_mode_message": "Es muss ein Spielmodus ausgewählt werden.",
"min_players_title": "Zu wenig Spieler:innen",
"min_players_message": "Es müssen mindestens 2 Spieler:innen hinzugefügt werden",
"no_name_title": "Kein Name",
"no_name_message": "Jeder Spieler muss einen Namen haben.",
"select_game_mode": "Spielmodus auswählen",
"point_limit_description": "Es wird so lange gespielt, bis ein:e Spieler:in mehr als {pointLimit} Punkte erreicht",
"@point_limit_description": {
"placeholders": {
"pointLimit": {
"type": "int"
}
}
},
"unlimited_description": "Dem Spiel sind keine Grenzen gesetzt. Es wird so lange gespielt, bis ihr keine Lust mehr habt.",
"results": "Ergebnisse",
"who_said_cabo": "Wer hat CABO gesagt?",
"kamikaze": "Kamikaze",
"done": "Fertig",
"next_round": "Nächste Runde",
"statistics": "Statistiken",
"delete_game": "Spiel löschen",
"new_game_same_settings": "Neues Spiel mit gleichen Einstellungen",
"export_game": "Spiel exportieren",
"game_process": "Spielverlauf",
"settings": "Einstellungen",
"cabo_penalty": "Cabo-Strafe",
"cabo_penalty_subtitle": "... für falsches Cabo sagen",
"point_limit": "Punkte-Limit",
"point_limit_subtitle": "... hier ist Schluss",
"reset_to_default": "Auf Standard zurücksetzen",
"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",
"error_found": "Fehler gefunden?",
"create_issue": "Issue erstellen",
"app_version": "App-Version",
"build": "Build",
"load_version": "Lade Version...",
"about_text": "Hey :) Danke, dass du als eine:r der ersten User meiner ersten eigenen App dabei bist! Ich hab sehr viel Arbeit in dieses Projekt gesteckt und auch, wenn ich (hoffentlich) an vieles Gedacht hab, wird auf jeden Fall noch nicht alles 100% funktionieren. Solltest du also irgendwelche Fehler entdecken oder Feedback zum Design oder der Benutzerfreundlichkeit haben, teile Sie mir gern über die Testflight App oder auf den dir bekannten Wegen mit. Danke! "
}

View File

@@ -1,93 +0,0 @@
{
"@@locale": "en",
"app_name": "Cabo Counter",
"round": "Round",
"rounds": "Rounds",
"mode": "Mode",
"points": "Points",
"unlimited": "Unlimited",
"delete": "Delete",
"cancel": "Cancel",
"game": "Game",
"ok": "OK",
"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.",
"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": {
"placeholders": {
"gameTitle": {
"type": "String"
}
}
},
"overview": "Overview",
"new_game": "New Game",
"game_title": "Game Title",
"select_mode": "Select a mode",
"add_player": "Add Player",
"create_game": "Create Game",
"max_players_title": "Maximum reached",
"max_players_message": "A maximum of 5 players can be added.",
"no_gameTitle_title": "No Title",
"no_gameTitle_message": "You must enter a title for the game.",
"no_mode_title": "No Mode",
"no_mode_message": "You must select a game mode.",
"min_players_title": "Too few players",
"min_players_message": "At least 2 players must be added.",
"no_name_title": "No Name",
"no_name_message": "Each player must have a name.",
"select_game_mode": "Select game mode",
"point_limit_description": "The game ends when a player reaches more than {pointLimit} points.",
"@point_limit_description": {
"placeholders": {
"pointLimit": {
"type": "int"
}
}
},
"unlimited_description": "There is no limit. The game continues until you decide to stop.",
"results": "Results",
"who_said_cabo": "Who said CABO?",
"kamikaze": "Kamikaze",
"done": "Done",
"next_round": "Next Round",
"statistics": "Statistics",
"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.",
"point_limit": "Point Limit",
"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",
"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 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

@@ -1,221 +0,0 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for German (`de`).
class AppLocalizationsDe extends AppLocalizations {
AppLocalizationsDe([String locale = 'de']) : super(locale);
@override
String get app_name => 'Cabo Counter';
@override
String get round => 'Runde';
@override
String get rounds => 'Runden';
@override
String get mode => 'Modus';
@override
String get points => 'Punkte';
@override
String get unlimited => 'Unbegrenzt';
@override
String get delete => 'Löschen';
@override
String get cancel => 'Abbrechen';
@override
String get game => 'Spiel';
@override
String get ok => 'OK';
@override
String get player => 'Spieler:in';
@override
String get players => 'Spieler:innen';
@override
String get name => 'Name';
@override
String get back => 'Zurück';
@override
String get home => 'Home';
@override
String get about => 'Über';
@override
String get empty_text_1 => 'Ganz schön leer hier...';
@override
String get empty_text_2 =>
'Füge über den Button oben rechts eine neue Runde hinzu';
@override
String get delete_game_title => 'Spiel löschen?';
@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.';
}
@override
String get overview => 'Übersicht';
@override
String get new_game => 'Neues Spiel';
@override
String get game_title => 'Titel des Spiels';
@override
String get select_mode => 'Wähle einen Modus';
@override
String get add_player => 'Spieler:in hinzufügen';
@override
String get create_game => 'Spiel erstellen';
@override
String get max_players_title => 'Maximale Anzahl erreicht';
@override
String get max_players_message =>
'Es können maximal 5 Spieler:innen hinzugefügt werden.';
@override
String get no_gameTitle_title => 'Kein Titel';
@override
String get no_gameTitle_message =>
'Es muss ein Titel für das Spiel eingegeben werden.';
@override
String get no_mode_title => 'Kein Modus';
@override
String get no_mode_message => 'Es muss ein Spielmodus ausgewählt werden.';
@override
String get min_players_title => 'Zu wenig Spieler:innen';
@override
String get min_players_message =>
'Es müssen mindestens 2 Spieler:innen hinzugefügt werden';
@override
String get no_name_title => 'Kein Name';
@override
String get no_name_message => 'Jeder Spieler muss einen Namen haben.';
@override
String get select_game_mode => 'Spielmodus auswählen';
@override
String point_limit_description(int pointLimit) {
return 'Es wird so lange gespielt, bis ein:e Spieler:in mehr als $pointLimit Punkte erreicht';
}
@override
String get unlimited_description =>
'Dem Spiel sind keine Grenzen gesetzt. Es wird so lange gespielt, bis ihr keine Lust mehr habt.';
@override
String get results => 'Ergebnisse';
@override
String get who_said_cabo => 'Wer hat CABO gesagt?';
@override
String get kamikaze => 'Kamikaze';
@override
String get done => 'Fertig';
@override
String get next_round => 'Nächste Runde';
@override
String get statistics => 'Statistiken';
@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 game_process => 'Spielverlauf';
@override
String get settings => 'Einstellungen';
@override
String get cabo_penalty => 'Cabo-Strafe';
@override
String get cabo_penalty_subtitle => '... für falsches Cabo sagen';
@override
String get point_limit => 'Punkte-Limit';
@override
String get point_limit_subtitle => '... hier ist Schluss';
@override
String get reset_to_default => 'Auf Standard zurücksetzen';
@override
String get game_data => 'Spieldaten';
@override
String get import_data => 'Daten importieren';
@override
String get export_data => 'Daten exportieren';
@override
String get error => 'Fehler';
@override
String get error_import => 'Datei konnte nicht importiert werden';
@override
String get error_export => 'Datei konnte nicht exportiert werden';
@override
String get error_found => 'Fehler gefunden?';
@override
String get create_issue => 'Issue erstellen';
@override
String get app_version => 'App-Version';
@override
String get build => 'Build';
@override
String get load_version => 'Lade Version...';
@override
String get about_text =>
'Hey :) Danke, dass du als eine:r der ersten User meiner ersten eigenen App dabei bist! Ich hab sehr viel Arbeit in dieses Projekt gesteckt und auch, wenn ich (hoffentlich) an vieles Gedacht hab, wird auf jeden Fall noch nicht alles 100% funktionieren. Solltest du also irgendwelche Fehler entdecken oder Feedback zum Design oder der Benutzerfreundlichkeit haben, teile Sie mir gern über die Testflight App oder auf den dir bekannten Wegen mit. Danke! ';
}

View File

@@ -1,218 +0,0 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for English (`en`).
class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get app_name => 'Cabo Counter';
@override
String get round => 'Round';
@override
String get rounds => 'Rounds';
@override
String get mode => 'Mode';
@override
String get points => 'Points';
@override
String get unlimited => 'Unlimited';
@override
String get delete => 'Delete';
@override
String get cancel => 'Cancel';
@override
String get game => 'Game';
@override
String get ok => 'OK';
@override
String get player => 'Player';
@override
String get players => 'Players';
@override
String get name => 'Name';
@override
String get back => 'Back';
@override
String get home => 'Home';
@override
String get about => 'About';
@override
String get empty_text_1 => 'Pretty empty here...';
@override
String get empty_text_2 =>
'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.';
}
@override
String get overview => 'Overview';
@override
String get new_game => 'New Game';
@override
String get game_title => 'Game Title';
@override
String get select_mode => 'Select a mode';
@override
String get add_player => 'Add Player';
@override
String get create_game => 'Create Game';
@override
String get max_players_title => 'Maximum reached';
@override
String get max_players_message => 'A maximum of 5 players can be added.';
@override
String get no_gameTitle_title => 'No Title';
@override
String get no_gameTitle_message => 'You must enter a title for the game.';
@override
String get no_mode_title => 'No Mode';
@override
String get no_mode_message => 'You must select a game mode.';
@override
String get min_players_title => 'Too few players';
@override
String get min_players_message => 'At least 2 players must be added.';
@override
String get no_name_title => 'No Name';
@override
String get no_name_message => 'Each player must have a name.';
@override
String get select_game_mode => 'Select game mode';
@override
String point_limit_description(int pointLimit) {
return 'The game ends when a player reaches more than $pointLimit points.';
}
@override
String get unlimited_description =>
'There is no limit. The game continues until you decide to stop.';
@override
String get results => 'Results';
@override
String get who_said_cabo => 'Who said CABO?';
@override
String get kamikaze => 'Kamikaze';
@override
String get done => 'Done';
@override
String get next_round => 'Next Round';
@override
String get statistics => 'Statistics';
@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 game_process => 'Spielverlauf';
@override
String get settings => 'Settings';
@override
String get cabo_penalty => 'Cabo Penalty';
@override
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.';
@override
String get reset_to_default => 'Reset to Default';
@override
String get game_data => 'Game Data';
@override
String get import_data => 'Import Data';
@override
String get export_data => 'Export Data';
@override
String get error => 'Error';
@override
String get error_import => 'Could not import file';
@override
String get error_export => 'Could not export file';
@override
String get error_found => 'Found a bug?';
@override
String get create_issue => 'Create Issue';
@override
String get app_version => 'App Version';
@override
String get build => 'Build';
@override
String get load_version => 'Loading version...';
@override
String get 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!';
}

167
lib/l10n/arb/app_de.arb Normal file
View File

@@ -0,0 +1,167 @@
{
"@@locale": "de",
"app_name": "Cabo Counter",
"round": "Runde",
"rounds": "Runden",
"mode": "Modus",
"points": "Punkte",
"unlimited": "Unbegrenzt",
"delete": "Löschen",
"cancel": "Abbrechen",
"game": "Spiel",
"games": "Spiele",
"gamemode": "Spielmodus",
"ok": "OK",
"player": "Spieler:in",
"players": "Spieler:innen",
"name": "Name",
"back": "Zurück",
"home": "Home",
"about": "Über",
"licenses": "Lizenzen",
"license_details": "Lizenzdetails",
"no_license_text": "Keine Lizenz verfügbar",
"legal_notice": "Impressum",
"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 das Spiel \"{gameTitle}\" löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.",
"@delete_game_message": {
"placeholders": {
"gameTitle": {
"type": "String"
}
}
},
"pre_rating_title": "Gefällt dir die App?",
"pre_rating_message": "Feedback hilft mir, die App zu verbessern. Vielen Dank!",
"yes": "Ja",
"no": "Nein",
"bad_rating_title": "Unzufrieden mit der App?",
"bad_rating_message": "Schreib mir gerne direkt eine E-Mail, damit wir dein Problem lösen können!",
"contact_email": "E-Mail schreiben",
"email_subject": "Feedback: Cabo Counter App",
"email_body": "Ich habe folgendes Feedback...",
"overview": "Übersicht",
"new_game": "Neues Spiel",
"game_title": "Titel des Spiels",
"select_mode": "Wähle einen Modus",
"add_player": "Spieler:in hinzufügen",
"create_game": "Spiel erstellen",
"max_players_title": "Maximale Anzahl erreicht",
"max_players_message": "Es können maximal 5 Spieler:innen hinzugefügt werden.",
"no_gameTitle_title": "Kein Titel",
"no_gameTitle_message": "Es muss ein Titel für das Spiel eingegeben werden.",
"no_mode_title": "Kein Modus",
"no_mode_message": "Es muss ein Spielmodus ausgewählt werden.",
"min_players_title": "Zu wenig Spieler:innen",
"min_players_message": "Es müssen mindestens 2 Spieler:innen hinzugefügt werden",
"no_name_title": "Kein Name",
"no_name_message": "Jede:r Spieler:in muss einen Namen haben.",
"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": {
"placeholders": {
"pointLimit": {
"type": "int"
}
}
},
"unlimited_description": "Es wird so lange gespielt, bis ihr keine Lust mehr habt. Das Spiel kann jederzeit manuell beendet werden.",
"results": "Ergebnisse",
"who_said_cabo": "Wer hat CABO gesagt?",
"kamikaze": "Kamikaze",
"who_has_kamikaze": "Wer hat Kamikaze?",
"done": "Fertig",
"next_round": "Nächste Runde",
"bonus_points_title": "Bonus-Punkte!",
"bonus_points_message": "{playerCount, plural, =1{{names} hat exakt das Punktelimit von {pointLimit} Punkten erreicht und bekommt deshalb {bonusPoints} Punkte abgezogen!} other{{names} haben exakt das Punktelimit von {pointLimit} Punkten erreicht und bekommen deshalb jeweils {bonusPoints} Punkte abgezogen!}}",
"@bonus_points_message": {
"placeholders": {
"playerCount": {
"type": "int"
},
"names": {
"type": "String"
},
"pointLimit": {
"type": "int"
},
"bonusPoints": {
"type": "int"
}
}
},
"end_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",
"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.",
"statistics": "Statistiken",
"point_overview": "Punktetabelle",
"scoring_history": "Spielverlauf",
"empty_graph_text": "Du musst mindestens eine Runde spielen, damit der Graph des Spielverlaufes angezeigt werden kann.",
"settings": "Einstellungen",
"cabo_penalty": "Cabo-Strafe",
"point_limit": "Punkte-Limit",
"standard_mode": "Standard-Modus",
"reset_to_default": "Auf Standard zurücksetzen",
"game_data": "Spieldaten",
"import_data": "Spieldaten importieren",
"export_data": "Spieldaten exportieren",
"delete_data": "Alle Spieldaten löschen",
"delete_data_title": "Spieldaten löschen?",
"delete_data_message": "Bist du sicher, dass du alle Spieldaten löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.",
"app": "App",
"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",
"wiki": "Wiki",
"app_version": "App-Version",
"privacy_policy": "Datenschutzerklärung",
"loading": "Lädt...",
"build": "Build-Nr."
}

167
lib/l10n/arb/app_en.arb Normal file
View File

@@ -0,0 +1,167 @@
{
"@@locale": "en",
"app_name": "Cabo Counter",
"round": "Round",
"rounds": "Rounds",
"mode": "Mode",
"points": "Points",
"unlimited": "Unlimited",
"delete": "Delete",
"cancel": "Cancel",
"game": "Game",
"games": "Games",
"gamemode": "Gamemode",
"ok": "OK",
"player": "Player",
"players": "Players",
"name": "Name",
"back": "Back",
"home": "Home",
"about": "About",
"licenses": "Licenses",
"license_details": "License Details",
"legal_notice": "Legal Notice",
"no_license_text": "No license available",
"empty_text_1": "Pretty empty here...",
"empty_text_2": "Create a new game using the button in the top right.",
"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": {
"placeholders": {
"gameTitle": {
"type": "String"
}
}
},
"pre_rating_title": "Do you like the app?",
"pre_rating_message": "Feedback helps me to continuously improve the app. Thank you!",
"yes": "Yes",
"no": "No",
"bad_rating_title": "Not satisfied?",
"bad_rating_message": "Feel free to send me an email directly so we can solve your problem!",
"contact_email": "Contact via E-Mail",
"email_subject": "Feedback: Cabo Counter App",
"email_body": "I have the following feedback...",
"overview": "Overview",
"new_game": "New Game",
"game_title": "Game Title",
"select_mode": "Select a mode",
"add_player": "Add Player",
"create_game": "Create Game",
"max_players_title": "Player Limit Reached",
"max_players_message": "You can add a maximum of 5 players.",
"no_gameTitle_title": "Missing Game Title",
"no_gameTitle_message": "Please enter a title for your game.",
"no_mode_title": "Game Mode Required",
"no_mode_message": "Please select a game mode to continue",
"min_players_title": "Too Few Players",
"min_players_message": "At least 2 players are required to start the game.",
"no_name_title": "Missing Player Names",
"no_name_message": "Each player must have a name.",
"select_game_mode": "Select game mode",
"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": {
"placeholders": {
"pointLimit": {
"type": "int"
}
}
},
"unlimited_description": "The game continues until you decide to stop playing. The game can be ended manually at any time.",
"results": "Results",
"who_said_cabo": "Who called Cabo?",
"kamikaze": "Kamikaze",
"who_has_kamikaze": "Who has Kamikaze?",
"done": "Done",
"next_round": "Next Round",
"bonus_points_title": "Bonus-Points!",
"bonus_points_message": "{playerCount, plural, =1{{names} has reached exactly the point limit of {pointLimit} points and therefore gets {bonusPoints} points deducted!} other{{names} have reached exactly the point limit of {pointLimit} points and therefore get {bonusPoints} points deducted!}}",
"@bonus_points_message": {
"placeholders": {
"playerCount": {
"type": "int"
},
"names": {
"type": "String"
},
"pointLimit": {
"type": "int"
},
"bonusPoints": {
"type": "int"
}
}
},
"end_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",
"delete_game": "Delete Game",
"new_game_same_settings": "New Game with same Settings",
"export_game": "Export Game",
"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.",
"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.",
"settings": "Settings",
"cabo_penalty": "Cabo Penalty",
"point_limit": "Point Limit",
"standard_mode": "Default Mode",
"reset_to_default": "Reset to Default",
"game_data": "Game Data",
"import_data": "Import Data",
"export_data": "Export Data",
"delete_data": "Delete all Game Data",
"delete_data_title": "Delete game data?",
"delete_data_message": "Are you sure you want to delete all game data? This action cannot be undone.",
"app": "App",
"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",
"wiki": "Wiki",
"app_version": "App Version",
"privacy_policy": "Privacy Policy",
"loading": "Loading...",
"build": "Build No."
}

View File

@@ -18,7 +18,7 @@ import 'app_localizations_en.dart';
/// `supportedLocales` list. For example:
///
/// ```dart
/// import 'l10n/app_localizations.dart';
/// import 'generated/app_localizations.dart';
///
/// return MaterialApp(
/// localizationsDelegates: AppLocalizations.localizationsDelegates,
@@ -152,6 +152,18 @@ abstract class AppLocalizations {
/// **'Spiel'**
String get game;
/// No description provided for @games.
///
/// In de, this message translates to:
/// **'Spiele'**
String get games;
/// No description provided for @gamemode.
///
/// In de, this message translates to:
/// **'Spielmodus'**
String get gamemode;
/// No description provided for @ok.
///
/// In de, this message translates to:
@@ -194,6 +206,30 @@ abstract class AppLocalizations {
/// **'Über'**
String get about;
/// No description provided for @licenses.
///
/// In de, this message translates to:
/// **'Lizenzen'**
String get licenses;
/// No description provided for @license_details.
///
/// In de, this message translates to:
/// **'Lizenzdetails'**
String get license_details;
/// No description provided for @no_license_text.
///
/// In de, this message translates to:
/// **'Keine Lizenz verfügbar'**
String get no_license_text;
/// No description provided for @legal_notice.
///
/// In de, this message translates to:
/// **'Impressum'**
String get legal_notice;
/// No description provided for @empty_text_1.
///
/// In de, this message translates to:
@@ -215,9 +251,63 @@ 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 @pre_rating_title.
///
/// In de, this message translates to:
/// **'Gefällt dir die App?'**
String get pre_rating_title;
/// No description provided for @pre_rating_message.
///
/// In de, this message translates to:
/// **'Feedback hilft mir, die App zu verbessern. Vielen Dank!'**
String get pre_rating_message;
/// No description provided for @yes.
///
/// In de, this message translates to:
/// **'Ja'**
String get yes;
/// No description provided for @no.
///
/// In de, this message translates to:
/// **'Nein'**
String get no;
/// No description provided for @bad_rating_title.
///
/// In de, this message translates to:
/// **'Unzufrieden mit der App?'**
String get bad_rating_title;
/// No description provided for @bad_rating_message.
///
/// In de, this message translates to:
/// **'Schreib mir gerne direkt eine E-Mail, damit wir dein Problem lösen können!'**
String get bad_rating_message;
/// No description provided for @contact_email.
///
/// In de, this message translates to:
/// **'E-Mail schreiben'**
String get contact_email;
/// No description provided for @email_subject.
///
/// In de, this message translates to:
/// **'Feedback: Cabo Counter App'**
String get email_subject;
/// No description provided for @email_body.
///
/// In de, this message translates to:
/// **'Ich habe folgendes Feedback...'**
String get email_body;
/// No description provided for @overview.
///
/// In de, this message translates to:
@@ -311,7 +401,7 @@ abstract class AppLocalizations {
/// No description provided for @no_name_message.
///
/// 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;
/// No description provided for @select_game_mode.
@@ -320,6 +410,24 @@ abstract class AppLocalizations {
/// **'Spielmodus auswählen'**
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.
///
/// In de, this message translates to:
@@ -329,7 +437,7 @@ abstract class AppLocalizations {
/// No description provided for @unlimited_description.
///
/// In de, this message translates to:
/// **'Dem Spiel sind keine Grenzen gesetzt. Es wird so lange gespielt, bis ihr keine Lust mehr habt.'**
/// **'Es wird so lange gespielt, bis ihr keine Lust mehr habt. Das Spiel kann jederzeit manuell beendet werden.'**
String get unlimited_description;
/// No description provided for @results.
@@ -350,6 +458,12 @@ abstract class AppLocalizations {
/// **'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.
///
/// In de, this message translates to:
@@ -362,11 +476,36 @@ abstract class AppLocalizations {
/// **'Nächste Runde'**
String get next_round;
/// No description provided for @statistics.
/// No description provided for @bonus_points_title.
///
/// In de, this message translates to:
/// **'Statistiken'**
String get statistics;
/// **'Bonus-Punkte!'**
String get bonus_points_title;
/// No description provided for @bonus_points_message.
///
/// In de, this message translates to:
/// **'{playerCount, plural, =1{{names} hat exakt das Punktelimit von {pointLimit} Punkten erreicht und bekommt deshalb {bonusPoints} Punkte abgezogen!} other{{names} haben exakt das Punktelimit von {pointLimit} Punkten erreicht und bekommen deshalb jeweils {bonusPoints} Punkte abgezogen!}}'**
String bonus_points_message(
int playerCount, String names, int pointLimit, int bonusPoints);
/// No description provided for @end_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.
///
/// In de, this message translates to:
/// **'Spiel beenden'**
String get end_game;
/// No description provided for @delete_game.
///
@@ -386,11 +525,53 @@ abstract class AppLocalizations {
/// **'Spiel exportieren'**
String get export_game;
/// No description provided for @game_process.
/// 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 @statistics.
///
/// In de, this message translates to:
/// **'Statistiken'**
String get statistics;
/// No description provided for @point_overview.
///
/// In de, this message translates to:
/// **'Punktetabelle'**
String get point_overview;
/// No description provided for @scoring_history.
///
/// In de, this message translates to:
/// **'Spielverlauf'**
String get game_process;
String get scoring_history;
/// No description provided for @empty_graph_text.
///
/// In de, this message translates to:
/// **'Du musst mindestens eine Runde spielen, damit der Graph des Spielverlaufes angezeigt werden kann.'**
String get empty_graph_text;
/// No description provided for @settings.
///
@@ -404,23 +585,17 @@ abstract class AppLocalizations {
/// **'Cabo-Strafe'**
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.
///
/// In de, this message translates to:
/// **'Punkte-Limit'**
String get point_limit;
/// No description provided for @point_limit_subtitle.
/// No description provided for @standard_mode.
///
/// In de, this message translates to:
/// **'... hier ist Schluss'**
String get point_limit_subtitle;
/// **'Standard-Modus'**
String get standard_mode;
/// No description provided for @reset_to_default.
///
@@ -437,32 +612,98 @@ abstract class AppLocalizations {
/// No description provided for @import_data.
///
/// In de, this message translates to:
/// **'Daten importieren'**
/// **'Spieldaten importieren'**
String get import_data;
/// No description provided for @export_data.
///
/// In de, this message translates to:
/// **'Daten exportieren'**
/// **'Spieldaten exportieren'**
String get export_data;
/// No description provided for @error.
/// No description provided for @delete_data.
///
/// In de, this message translates to:
/// **'Alle Spieldaten löschen'**
String get delete_data;
/// No description provided for @delete_data_title.
///
/// In de, this message translates to:
/// **'Spieldaten löschen?'**
String get delete_data_title;
/// No description provided for @delete_data_message.
///
/// In de, this message translates to:
/// **'Bist du sicher, dass du alle Spieldaten löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.'**
String get delete_data_message;
/// No description provided for @app.
///
/// In de, this message translates to:
/// **'App'**
String get app;
/// 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.
///
@@ -476,29 +717,35 @@ abstract class AppLocalizations {
/// **'Issue erstellen'**
String get create_issue;
/// No description provided for @wiki.
///
/// In de, this message translates to:
/// **'Wiki'**
String get wiki;
/// No description provided for @app_version.
///
/// In de, this message translates to:
/// **'App-Version'**
String get app_version;
/// No description provided for @privacy_policy.
///
/// In de, this message translates to:
/// **'Datenschutzerklärung'**
String get privacy_policy;
/// No description provided for @loading.
///
/// In de, this message translates to:
/// **'Lädt...'**
String get loading;
/// No description provided for @build.
///
/// In de, this message translates to:
/// **'Build'**
/// **'Build-Nr.'**
String get build;
/// No description provided for @load_version.
///
/// In de, this message translates to:
/// **'Lade Version...'**
String get load_version;
/// No description provided for @about_text.
///
/// In de, this message translates to:
/// **'Hey :) Danke, dass du als eine:r der ersten User meiner ersten eigenen App dabei bist! Ich hab sehr viel Arbeit in dieses Projekt gesteckt und auch, wenn ich (hoffentlich) an vieles Gedacht hab, wird auf jeden Fall noch nicht alles 100% funktionieren. Solltest du also irgendwelche Fehler entdecken oder Feedback zum Design oder der Benutzerfreundlichkeit haben, teile Sie mir gern über die Testflight App oder auf den dir bekannten Wegen mit. Danke! '**
String get about_text;
}
class _AppLocalizationsDelegate

View File

@@ -0,0 +1,373 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for German (`de`).
class AppLocalizationsDe extends AppLocalizations {
AppLocalizationsDe([String locale = 'de']) : super(locale);
@override
String get app_name => 'Cabo Counter';
@override
String get round => 'Runde';
@override
String get rounds => 'Runden';
@override
String get mode => 'Modus';
@override
String get points => 'Punkte';
@override
String get unlimited => 'Unbegrenzt';
@override
String get delete => 'Löschen';
@override
String get cancel => 'Abbrechen';
@override
String get game => 'Spiel';
@override
String get games => 'Spiele';
@override
String get gamemode => 'Spielmodus';
@override
String get ok => 'OK';
@override
String get player => 'Spieler:in';
@override
String get players => 'Spieler:innen';
@override
String get name => 'Name';
@override
String get back => 'Zurück';
@override
String get home => 'Home';
@override
String get about => 'Über';
@override
String get licenses => 'Lizenzen';
@override
String get license_details => 'Lizenzdetails';
@override
String get no_license_text => 'Keine Lizenz verfügbar';
@override
String get legal_notice => 'Impressum';
@override
String get empty_text_1 => 'Ganz schön leer hier...';
@override
String get empty_text_2 =>
'Füge über den Button oben rechts eine neue Runde hinzu';
@override
String get delete_game_title => 'Spiel löschen?';
@override
String delete_game_message(String gameTitle) {
return 'Bist du sicher, dass du das Spiel \"$gameTitle\" löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.';
}
@override
String get pre_rating_title => 'Gefällt dir die App?';
@override
String get pre_rating_message =>
'Feedback hilft mir, die App zu verbessern. Vielen Dank!';
@override
String get yes => 'Ja';
@override
String get no => 'Nein';
@override
String get bad_rating_title => 'Unzufrieden mit der App?';
@override
String get bad_rating_message =>
'Schreib mir gerne direkt eine E-Mail, damit wir dein Problem lösen können!';
@override
String get contact_email => 'E-Mail schreiben';
@override
String get email_subject => 'Feedback: Cabo Counter App';
@override
String get email_body => 'Ich habe folgendes Feedback...';
@override
String get overview => 'Übersicht';
@override
String get new_game => 'Neues Spiel';
@override
String get game_title => 'Titel des Spiels';
@override
String get select_mode => 'Wähle einen Modus';
@override
String get add_player => 'Spieler:in hinzufügen';
@override
String get create_game => 'Spiel erstellen';
@override
String get max_players_title => 'Maximale Anzahl erreicht';
@override
String get max_players_message =>
'Es können maximal 5 Spieler:innen hinzugefügt werden.';
@override
String get no_gameTitle_title => 'Kein Titel';
@override
String get no_gameTitle_message =>
'Es muss ein Titel für das Spiel eingegeben werden.';
@override
String get no_mode_title => 'Kein Modus';
@override
String get no_mode_message => 'Es muss ein Spielmodus ausgewählt werden.';
@override
String get min_players_title => 'Zu wenig Spieler:innen';
@override
String get min_players_message =>
'Es müssen mindestens 2 Spieler:innen hinzugefügt werden';
@override
String get no_name_title => 'Kein Name';
@override
String get no_name_message => 'Jede:r Spieler:in muss einen Namen haben.';
@override
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
String point_limit_description(int pointLimit) {
return 'Es wird so lange gespielt, bis ein:e Spieler:in mehr als $pointLimit Punkte erreicht';
}
@override
String get unlimited_description =>
'Es wird so lange gespielt, bis ihr keine Lust mehr habt. Das Spiel kann jederzeit manuell beendet werden.';
@override
String get results => 'Ergebnisse';
@override
String get who_said_cabo => 'Wer hat CABO gesagt?';
@override
String get kamikaze => 'Kamikaze';
@override
String get who_has_kamikaze => 'Wer hat Kamikaze?';
@override
String get done => 'Fertig';
@override
String get next_round => 'Nächste Runde';
@override
String get bonus_points_title => 'Bonus-Punkte!';
@override
String bonus_points_message(
int playerCount, String names, int pointLimit, int bonusPoints) {
String _temp0 = intl.Intl.pluralLogic(
playerCount,
locale: localeName,
other:
'$names haben exakt das Punktelimit von $pointLimit Punkten erreicht und bekommen deshalb jeweils $bonusPoints Punkte abgezogen!',
one:
'$names hat exakt das Punktelimit von $pointLimit Punkten erreicht und bekommt deshalb $bonusPoints Punkte abgezogen!',
);
return '$_temp0';
}
@override
String get end_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
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 statistics => 'Statistiken';
@override
String get point_overview => 'Punktetabelle';
@override
String get scoring_history => 'Spielverlauf';
@override
String get empty_graph_text =>
'Du musst mindestens eine Runde spielen, damit der Graph des Spielverlaufes angezeigt werden kann.';
@override
String get settings => 'Einstellungen';
@override
String get cabo_penalty => 'Cabo-Strafe';
@override
String get point_limit => 'Punkte-Limit';
@override
String get standard_mode => 'Standard-Modus';
@override
String get reset_to_default => 'Auf Standard zurücksetzen';
@override
String get game_data => 'Spieldaten';
@override
String get import_data => 'Spieldaten importieren';
@override
String get export_data => 'Spieldaten exportieren';
@override
String get delete_data => 'Alle Spieldaten löschen';
@override
String get delete_data_title => 'Spieldaten löschen?';
@override
String get delete_data_message =>
'Bist du sicher, dass du alle Spieldaten löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.';
@override
String get app => 'App';
@override
String get import_success_title => 'Import erfolgreich';
@override
String get import_success_message =>
'Die Spieldaten wurden erfolgreich importiert.';
@override
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?';
@override
String get create_issue => 'Issue erstellen';
@override
String get wiki => 'Wiki';
@override
String get app_version => 'App-Version';
@override
String get privacy_policy => 'Datenschutzerklärung';
@override
String get loading => 'Lädt...';
@override
String get build => 'Build-Nr.';
}

View File

@@ -0,0 +1,370 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for English (`en`).
class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get app_name => 'Cabo Counter';
@override
String get round => 'Round';
@override
String get rounds => 'Rounds';
@override
String get mode => 'Mode';
@override
String get points => 'Points';
@override
String get unlimited => 'Unlimited';
@override
String get delete => 'Delete';
@override
String get cancel => 'Cancel';
@override
String get game => 'Game';
@override
String get games => 'Games';
@override
String get gamemode => 'Gamemode';
@override
String get ok => 'OK';
@override
String get player => 'Player';
@override
String get players => 'Players';
@override
String get name => 'Name';
@override
String get back => 'Back';
@override
String get home => 'Home';
@override
String get about => 'About';
@override
String get licenses => 'Licenses';
@override
String get license_details => 'License Details';
@override
String get no_license_text => 'No license available';
@override
String get legal_notice => 'Legal Notice';
@override
String get empty_text_1 => 'Pretty empty here...';
@override
String get empty_text_2 =>
'Create a new game using the button in the top right.';
@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.';
}
@override
String get pre_rating_title => 'Do you like the app?';
@override
String get pre_rating_message =>
'Feedback helps me to continuously improve the app. Thank you!';
@override
String get yes => 'Yes';
@override
String get no => 'No';
@override
String get bad_rating_title => 'Not satisfied?';
@override
String get bad_rating_message =>
'Feel free to send me an email directly so we can solve your problem!';
@override
String get contact_email => 'Contact via E-Mail';
@override
String get email_subject => 'Feedback: Cabo Counter App';
@override
String get email_body => 'I have the following feedback...';
@override
String get overview => 'Overview';
@override
String get new_game => 'New Game';
@override
String get game_title => 'Game Title';
@override
String get select_mode => 'Select a mode';
@override
String get add_player => 'Add Player';
@override
String get create_game => 'Create Game';
@override
String get max_players_title => 'Player Limit Reached';
@override
String get max_players_message => 'You can add a maximum of 5 players.';
@override
String get no_gameTitle_title => 'Missing Game Title';
@override
String get no_gameTitle_message => 'Please enter a title for your game.';
@override
String get no_mode_title => 'Game Mode Required';
@override
String get no_mode_message => 'Please select a game mode to continue';
@override
String get min_players_title => 'Too Few Players';
@override
String get min_players_message =>
'At least 2 players are required to start the game.';
@override
String get no_name_title => 'Missing Player Names';
@override
String get no_name_message => 'Each player must have a name.';
@override
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
String point_limit_description(int pointLimit) {
return 'The game ends when a player scores more than $pointLimit points.';
}
@override
String get unlimited_description =>
'The game continues until you decide to stop playing. The game can be ended manually at any time.';
@override
String get results => 'Results';
@override
String get who_said_cabo => 'Who called Cabo?';
@override
String get kamikaze => 'Kamikaze';
@override
String get who_has_kamikaze => 'Who has Kamikaze?';
@override
String get done => 'Done';
@override
String get next_round => 'Next Round';
@override
String get bonus_points_title => 'Bonus-Points!';
@override
String bonus_points_message(
int playerCount, String names, int pointLimit, int bonusPoints) {
String _temp0 = intl.Intl.pluralLogic(
playerCount,
locale: localeName,
other:
'$names have reached exactly the point limit of $pointLimit points and therefore get $bonusPoints points deducted!',
one:
'$names has reached exactly the point limit of $pointLimit points and therefore gets $bonusPoints points deducted!',
);
return '$_temp0';
}
@override
String get end_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
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 statistics => 'Statistics';
@override
String get point_overview => 'Point Overview';
@override
String get scoring_history => 'Scoring History';
@override
String get empty_graph_text =>
'You must play at least one round for the game progress graph to be displayed.';
@override
String get settings => 'Settings';
@override
String get cabo_penalty => 'Cabo Penalty';
@override
String get point_limit => 'Point Limit';
@override
String get standard_mode => 'Default Mode';
@override
String get reset_to_default => 'Reset to Default';
@override
String get game_data => 'Game Data';
@override
String get import_data => 'Import Data';
@override
String get export_data => 'Export Data';
@override
String get delete_data => 'Delete all Game Data';
@override
String get delete_data_title => 'Delete game data?';
@override
String get delete_data_message =>
'Are you sure you want to delete all game data? This action cannot be undone.';
@override
String get app => 'App';
@override
String get import_success_title => 'Import successful';
@override
String get import_success_message =>
'The game data has been successfully imported.';
@override
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?';
@override
String get create_issue => 'Create Issue';
@override
String get wiki => 'Wiki';
@override
String get app_version => 'App Version';
@override
String get privacy_policy => 'Privacy Policy';
@override
String get loading => 'Loading...';
@override
String get build => 'Build No.';
}

View File

@@ -1,19 +1,21 @@
import 'package:cabo_counter/l10n/app_localizations.dart';
import 'package:cabo_counter/core/custom_theme.dart';
import 'package:cabo_counter/l10n/generated/app_localizations.dart';
import 'package:cabo_counter/presentation/views/tab_view.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:cabo_counter/services/version_service.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Ensure the app runs in portrait mode only
await SystemChrome.setPreferredOrientations(
[DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]);
// Initialize services
await ConfigService.initConfig();
Globals.pointLimit = await ConfigService.getPointLimit();
Globals.caboPenalty = await ConfigService.getCaboPenalty();
await VersionService.init();
runApp(const App());
}
@@ -39,6 +41,9 @@ class _AppState extends State<App> with WidgetsBindingObserver {
}
@override
/// Every time the app goes into the background or is closed,
/// save the current game sessions to local storage.
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.detached) {
@@ -65,6 +70,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
return supportedLocales.first;
},
theme: CupertinoThemeData(
applyThemeToAll: true,
brightness: Brightness.dark,
primaryColor: CustomTheme.primaryColor,
scaffoldBackgroundColor: CustomTheme.backgroundColor,

View File

@@ -0,0 +1,95 @@
import 'package:cabo_counter/core/constants.dart';
import 'package:cabo_counter/l10n/generated/app_localizations.dart';
import 'package:cabo_counter/presentation/views/about/licenses/license_view.dart';
import 'package:cabo_counter/services/version_service.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:url_launcher/url_launcher.dart';
/// A view that displays information about the app, including its name, version,
/// privacy policy, imprint, and licenses.
class AboutView extends StatelessWidget {
const AboutView({super.key});
@override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
resizeToAvoidBottomInset: false,
navigationBar: CupertinoNavigationBar(
middle: Text(AppLocalizations.of(context).about),
),
child: SafeArea(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(0, 10, 0, 0),
child: Text(
AppLocalizations.of(context).app_name,
style: const TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
),
),
),
Text(
'${AppLocalizations.of(context).app_version} ${VersionService.getVersionWithBuild()}',
style: TextStyle(fontSize: 15, color: Colors.grey[300]),
),
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 15),
child: SizedBox(
height: 200,
child: Image.asset('assets/cabo_counter-logo_rounded.png'),
)),
CupertinoButton(
sizeStyle: CupertinoButtonSize.medium,
padding: EdgeInsets.zero,
child: Text(AppLocalizations.of(context).privacy_policy),
onPressed: () =>
launchUrl(Uri.parse(Constants.kPrivacyPolicyLink)),
),
CupertinoButton(
sizeStyle: CupertinoButtonSize.medium,
padding: EdgeInsets.zero,
child: Text(AppLocalizations.of(context).legal_notice),
onPressed: () => launchUrl(Uri.parse(Constants.kLegalLink)),
),
CupertinoButton(
sizeStyle: CupertinoButtonSize.medium,
padding: EdgeInsets.zero,
child: Text(AppLocalizations.of(context).licenses),
onPressed: () => Navigator.push(context,
CupertinoPageRoute(builder: (_) => const LicenseView()))),
const SizedBox(
height: 10,
),
const Text(
'\u00A9 Felix Kirchner',
style: TextStyle(fontSize: 16),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
onPressed: () =>
launchUrl(Uri.parse(Constants.kInstagramLink)),
icon: const Icon(FontAwesomeIcons.instagram)),
IconButton(
onPressed: () =>
launchUrl(Uri.parse('mailto:${Constants.kEmail}')),
icon: const Icon(CupertinoIcons.envelope)),
IconButton(
onPressed: () =>
launchUrl(Uri.parse(Constants.kGithubLink)),
icon: const Icon(FontAwesomeIcons.github)),
],
),
],
),
)));
}
}

View File

@@ -0,0 +1,65 @@
import 'package:cabo_counter/core/custom_theme.dart';
import 'package:cabo_counter/l10n/generated/app_localizations.dart'
show AppLocalizations;
import 'package:flutter/cupertino.dart';
/// Displays the details of a specific open source software license in a Cupertino-style view.
///
/// This view presents the license title and its full text in a scrollable layout.
///
/// Required parameters:
/// - [title]: The name of the license.
/// - [license]: The full license text to display.
class LicenseDetailView extends StatelessWidget {
final String title, license;
const LicenseDetailView(
{super.key, required this.title, required this.license});
@override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(
AppLocalizations.of(context).license_details,
),
previousPageTitle: AppLocalizations.of(context).licenses,
),
child: SafeArea(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
children: [
Padding(
padding:
const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
child: FittedBox(
fit: BoxFit.fill,
child: Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
),
Container(
margin: const EdgeInsets.all(8),
padding:
const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
decoration: BoxDecoration(
color: CustomTheme.buttonBackgroundColor,
borderRadius: BorderRadius.circular(16)),
child: Text(
license,
style: const TextStyle(fontSize: 15),
),
)
],
),
),
),
);
}
}

View File

@@ -0,0 +1,67 @@
import 'package:cabo_counter/core/custom_theme.dart';
import 'package:cabo_counter/l10n/generated/app_localizations.dart';
import 'package:cabo_counter/presentation/views/about/licenses/license_detail_view.dart';
import 'package:cabo_counter/presentation/views/about/licenses/oss_licenses.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
/// Displays a list of open source software licenses used in the app.
///
/// Users can tap on a license to view its details on a separate screen.
/// This view uses a Cupertino design and supports localization.
///
/// See also:
/// - [LicenseDetailView] for displaying license details.
/// - [ossLicenses] for the list of licenses.
class LicenseView extends StatelessWidget {
const LicenseView({super.key});
@override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(AppLocalizations.of(context).licenses),
previousPageTitle: AppLocalizations.of(context).about,
),
child: SafeArea(
child: ListView.builder(
physics: const BouncingScrollPhysics(),
itemCount: ossLicenses.length,
itemBuilder: (_, index) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(8),
),
child: CupertinoListTile(
backgroundColor: CustomTheme.backgroundColor,
onTap: () {
Navigator.push(
context,
CupertinoPageRoute(
builder: (_) => LicenseDetailView(
title: ossLicenses[index].name,
license: ossLicenses[index].license ??
AppLocalizations.of(context).no_license_text,
),
),
);
},
trailing: const CupertinoListTileChevron(),
title: Text(
ossLicenses[index].name,
style: GoogleFonts.roboto(),
),
subtitle: Text(ossLicenses[index].description),
),
),
);
},
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,552 @@
import 'package:cabo_counter/core/constants.dart';
import 'package:cabo_counter/core/custom_theme.dart';
import 'package:cabo_counter/data/game_manager.dart';
import 'package:cabo_counter/data/game_session.dart';
import 'package:cabo_counter/l10n/generated/app_localizations.dart';
import 'package:cabo_counter/presentation/views/home/active_game/graph_view.dart';
import 'package:cabo_counter/presentation/views/home/active_game/mode_selection_view.dart';
import 'package:cabo_counter/presentation/views/home/active_game/points_view.dart';
import 'package:cabo_counter/presentation/views/home/active_game/round_view.dart';
import 'package:cabo_counter/presentation/views/home/create_game_view.dart';
import 'package:cabo_counter/services/config_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/material.dart';
/// Displays the active game view, showing game details, player rankings, rounds, and statistics.
///
/// This view allows users to interact with an ongoing game session, including viewing player scores,
/// navigating through rounds, ending or deleting the game, exporting game data, and starting a new game
/// with the same settings. It also provides visual feedback such as confetti animation when the game ends.
///
/// The widget listens to changes in the provided [GameSession] and updates the UI accordingly.
class ActiveGameView extends StatefulWidget {
final GameSession gameSession;
const ActiveGameView({super.key, required this.gameSession});
@override
// ignore: library_private_types_in_public_api
_ActiveGameViewState createState() => _ActiveGameViewState();
}
class _ActiveGameViewState extends State<ActiveGameView> {
/// Constant value to represent a press on the cancel button in round view.
static const int kRoundCancelled = -1;
final confettiController = ConfettiController(
duration: const Duration(seconds: 10),
);
late final GameSession gameSession;
/// A list of the ranks for each player corresponding to their index in sortedPlayerIndices
late List<int> denseRanks;
/// A list of player indices sorted by their scores in ascending order.
late List<int> sortedPlayerIndices;
@override
void initState() {
super.initState();
gameSession = widget.gameSession;
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
ListenableBuilder(
listenable: gameSession,
builder: (context, _) {
sortedPlayerIndices = _getSortedPlayerIndices();
denseRanks = _calculateDenseRank(
gameSession.playerScores, sortedPlayerIndices);
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
previousPageTitle: AppLocalizations.of(context).games,
middle: Text(AppLocalizations.of(context).overview),
),
child: SafeArea(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(10, 0, 0, 0),
child: Text(
AppLocalizations.of(context).game,
style: CustomTheme.rowTitle,
),
),
CupertinoListTile(
title: Text(AppLocalizations.of(context).name),
trailing: Text(
gameSession.gameTitle,
style: TextStyle(color: CustomTheme.primaryColor),
),
),
CupertinoListTile(
title: Text(AppLocalizations.of(context).mode),
trailing: Text(
gameSession.isPointsLimitEnabled
? '${ConfigService.getPointLimit()} ${AppLocalizations.of(context).points}'
: AppLocalizations.of(context).unlimited,
style: TextStyle(color: CustomTheme.primaryColor),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 0, 0),
child: Text(
AppLocalizations.of(context).players,
style: CustomTheme.rowTitle,
),
),
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: gameSession.players.length,
itemBuilder: (BuildContext context, int index) {
int playerIndex = sortedPlayerIndices[index];
return CupertinoListTile(
padding:
const EdgeInsets.fromLTRB(14, 5, 14, 0),
title: Row(
children: [
_getPlacementTextWidget(index),
const SizedBox(width: 5),
Text(
gameSession.players[playerIndex],
style: const TextStyle(
fontWeight: FontWeight.bold),
),
],
),
trailing: Row(
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
? (const Text('\u{2705}',
style: TextStyle(fontSize: 22)))
: const Text('\u{23F3}',
style: TextStyle(fontSize: 22)),
onTap: () async {
_openRoundView(context, index + 1);
},
));
},
),
Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 0, 0),
child: Text(
AppLocalizations.of(context).statistics,
style: CustomTheme.rowTitle,
),
),
Column(
children: [
CupertinoListTile(
title: Text(
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();
}
}),
),
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.push(
context,
CupertinoPageRoute(
builder: (_) => CreateGameView(
gameTitle:
gameSession.gameTitle,
gameMode: widget.gameSession
.isPointsLimitEnabled ==
true
? GameMode.pointLimit
: GameMode.unlimited,
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.
/// 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(
isDestructiveAction: true,
child: Text(
AppLocalizations.of(context).end_game,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
onPressed: () {
setState(() {
gameManager.endGame(gameSession.id);
_playFinishAnimation(context);
});
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(gameSession.players.length, (index) => index);
// Sort the indices based on the summed points
playerIndices.sort((a, b) {
int scoreA = gameSession.playerScores[a];
int scoreB = gameSession.playerScores[b];
if (scoreA != scoreB) {
return scoreA.compareTo(scoreB);
}
return a.compareTo(b);
});
return playerIndices;
}
/// Calculates the dense rank for a player based on their index in the sorted list of players.
List<int> _calculateDenseRank(
List<int> playerScores, List<int> sortedIndices) {
List<int> denseRanks = [];
int rank = 1;
for (int i = 0; i < sortedIndices.length; i++) {
if (i > 0) {
int prevScore = playerScores[sortedIndices[i - 1]];
int currScore = playerScores[sortedIndices[i]];
if (currScore != prevScore) {
rank++;
}
}
denseRanks.add(rank);
}
return denseRanks;
}
/// Returns a text widget representing the placement text based on the given placement number.
/// [index] is the index of the player in [players] list,
Text _getPlacementTextWidget(int index) {
int placement = denseRanks[index];
switch (placement) {
case 1:
return const Text('\u{1F947}', style: TextStyle(fontSize: 22)); // 🥇
case 2:
return const Text('\u{1F948}', style: TextStyle(fontSize: 22)); // 🥈
case 3:
return const Text('\u{1F949}', style: TextStyle(fontSize: 22)); // 🥉
default:
return Text(' $placement.',
style: const TextStyle(fontWeight: FontWeight.bold));
}
}
/// Shows a dialog to confirm deleting the game session.
Future<bool> _showDeleteGameDialog() async {
return await showCupertinoDialog<bool>(
context: context,
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;
}
/// Removes the game session in the game manager and navigates back to the previous screen.
/// If the game session does not exist in the game list, it shows an error dialog.
Future<void> _removeGameSession(GameSession gameSession) async {
if (gameManager.gameExistsInGameList(gameSession.id)) {
Navigator.pop(context);
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(BuildContext context, int roundNumber) async {
final round = await Navigator.of(context, rootNavigator: true).push(
CupertinoPageRoute(
fullscreenDialog: true,
builder: (context) => RoundView(
gameSession: gameSession,
roundNumber: roundNumber,
),
),
);
// If the user presses the cancel button
if (round == kRoundCancelled) return;
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 {
await Future.delayed(
const Duration(milliseconds: Constants.kRoundViewDelay));
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.kPopUpDelay));
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

@@ -0,0 +1,139 @@
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:syncfusion_flutter_charts/charts.dart';
/// A widget that displays the cumulative scoring history of a game session as a line graph.
///
/// The [GraphView] visualizes the progression of each player's score over multiple rounds
/// using a line chart. It supports dynamic coloring for each player, axis formatting,
/// and handles cases where insufficient data is available to render the graph.
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.
final List<Color> lineColors = [
CustomTheme.graphColor1,
CustomTheme.graphColor2,
CustomTheme.graphColor3,
CustomTheme.graphColor4,
CustomTheme.graphColor5
];
@override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(AppLocalizations.of(context).scoring_history),
previousPageTitle: AppLocalizations.of(context).overview,
),
child: SafeArea(
child: Visibility(
visible: widget.gameSession.roundNumber > 1 ||
widget.gameSession.isGameFinished,
replacement: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Center(
child: Icon(CupertinoIcons.chart_bar_alt_fill, size: 60),
),
const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
child: Text(
AppLocalizations.of(context).empty_graph_text,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16),
),
),
],
),
child: SfCartesianChart(
enableAxisAnimation: true,
legend: const Legend(
overflowMode: LegendItemOverflowMode.wrap,
isVisible: true,
position: LegendPosition.bottom),
primaryXAxis: const NumericAxis(
labelStyle: TextStyle(fontWeight: FontWeight.bold),
interval: 1,
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.
/// 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, num), 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.scoreUpdates[i];
cumulativeScores[i].add(runningTotals[i]);
}
}
const double jitterStep = 0.03;
/// 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 + 1,
(j) => (
j,
j == 0 || cumulativeScores[i][j - 1] == 0
? 0 // 0 points at the start of the game or when the value is 0 (don't subtract jitter step)
// Adds a small jitter to the cumulative scores to prevent overlapping data points in the graph.
// The jitter is centered around zero by subtracting playerCount ~/ 2 from the player index i.
: cumulativeScores[i][j - 1] + (i - playerCount ~/ 2) * jitterStep
),
);
/// Create a LineSeries for the player
/// The xValueMapper maps the round number, and the yValueMapper maps the cumulative score.
return LineSeries<(int, num), int>(
name: playerNames[i],
dataSource: data,
xValueMapper: (record, _) => record.$1,
yValueMapper: (record, _) => record.$2,
markerSettings: const MarkerSettings(isVisible: true),
color: lineColors[i],
);
});
}
}

View File

@@ -0,0 +1,85 @@
import 'package:cabo_counter/core/custom_theme.dart';
import 'package:cabo_counter/l10n/generated/app_localizations.dart';
import 'package:flutter/cupertino.dart';
enum GameMode {
none,
pointLimit,
unlimited,
}
/// A stateless widget that displays a menu for selecting the game mode.
///
/// The [ModeSelectionMenu] allows the user to choose between different game modes:
/// - Point limit mode with a specified [pointLimit]
/// - Unlimited mode
/// - Optionally, no default mode if [showDeselection] is true
class ModeSelectionMenu extends StatelessWidget {
final int pointLimit;
final bool showDeselection;
const ModeSelectionMenu(
{super.key, required this.pointLimit, required this.showDeselection});
@override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(AppLocalizations.of(context).gamemode),
previousPageTitle:
!showDeselection ? AppLocalizations.of(context).new_game : '',
),
child: ListView(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(0, 16, 0, 0),
child: CupertinoListTile(
title: Text('$pointLimit ${AppLocalizations.of(context).points}',
style: CustomTheme.modeTitle),
subtitle: Text(
AppLocalizations.of(context)
.point_limit_description(pointLimit),
style: CustomTheme.modeDescription,
maxLines: 3,
),
onTap: () {
Navigator.pop(context, GameMode.pointLimit);
},
),
),
Padding(
padding: const EdgeInsets.fromLTRB(0, 16, 0, 0),
child: CupertinoListTile(
title: Text(AppLocalizations.of(context).unlimited,
style: CustomTheme.modeTitle),
subtitle: Text(
AppLocalizations.of(context).unlimited_description,
style: CustomTheme.modeDescription,
maxLines: 3,
),
onTap: () {
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,262 @@
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';
/// Displays an overview of points for each player and round in the current game session.
///
/// The [PointsView] widget shows a table with all rounds and player scores,
/// including score updates and highlights for players who said "Cabo".
/// It uses a Cupertino-style layout and adapts to the number of players.
///
/// Requires a [GameSession] to provide player and round data.
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).overview,
),
child: SafeArea(child: LayoutBuilder(builder: (context, constraints) {
const double caboFieldWidthFactor = 0.2;
const double tablePadding = 8;
final int playerCount = widget.gameSession.players.length;
const double roundColWidth = 35;
final double playerColWidth =
(constraints.maxWidth - roundColWidth - (tablePadding)) /
playerCount;
return Column(
children: [
ConstrainedBox(
constraints: BoxConstraints(maxWidth: constraints.maxWidth),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: tablePadding),
child: DataTable(
dataRowMaxHeight: 0,
dataRowMinHeight: 0,
columnSpacing: 0,
horizontalMargin: 0,
columns: [
const DataColumn(
label: SizedBox(
width: roundColWidth,
child: Text(
'#',
style: TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
),
numeric: true,
),
...widget.gameSession.players.map(
(player) => DataColumn(
label: SizedBox(
width: playerColWidth,
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8),
child: Text(
player,
style: const TextStyle(
fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
softWrap: true,
maxLines: 2,
textAlign: TextAlign.center,
),
),
),
),
),
],
rows: const [],
),
),
),
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.vertical,
child: ConstrainedBox(
constraints:
BoxConstraints(maxWidth: constraints.maxWidth),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: tablePadding),
child: DataTable(
dataRowMaxHeight: 75,
dataRowMinHeight: 75,
columnSpacing: 0,
horizontalMargin: 0,
headingRowHeight: 0,
columns: [
const DataColumn(
label: SizedBox(
width: roundColWidth,
child: Text(
'#',
style: TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
),
numeric: true,
),
...widget.gameSession.players.map(
(player) => DataColumn(
label: SizedBox(
width: playerColWidth,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8),
child: Text(
player,
style: const TextStyle(
fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
softWrap: true,
maxLines: 2,
textAlign: TextAlign.center,
),
),
),
),
),
],
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: Padding(
padding: const EdgeInsets.symmetric(
vertical: 6.0),
child: Container(
width: playerColWidth *
(playerCount *
caboFieldWidthFactor), // Adjust width based on amount of players
decoration: BoxDecoration(
color: saidCabo
? CustomTheme
.buttonBackgroundColor
: CupertinoColors.transparent,
borderRadius:
BorderRadius.circular(5),
),
child: Column(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
const SizedBox(
height: 5,
),
Container(
padding: const EdgeInsets
.symmetric(
horizontal: 6,
vertical: 2),
decoration: BoxDecoration(
color: update <= 0
? CustomTheme
.pointLossColor
: CustomTheme
.pointGainColor,
borderRadius:
BorderRadius.circular(
6),
),
child: Text(
'${update >= 0 ? '+' : ''}$update',
style: const TextStyle(
color:
CupertinoColors.white,
fontWeight:
FontWeight.bold,
),
),
),
const SizedBox(height: 4),
Text(
'$score',
style: TextStyle(
color: CustomTheme.white,
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

@@ -0,0 +1,557 @@
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:cabo_counter/presentation/widgets/custom_button.dart';
import 'package:cabo_counter/services/local_storage_service.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
/// A view for displaying and managing a single round
///
/// This widget allows users to input and review scores for each player in a round,
/// select the player who called CABO, and handle special cases such as Kamikaze rounds.
/// It manages the round state, validates input, and coordinates navigation between rounds.
///
/// Features:
/// - Rotates player order based on the previous round's winner.
/// - Supports Kamikaze rounds with dedicated UI and logic.
/// - Handles score input, validation, and updates to the game session.
/// - Displays bonus point popups when applicable.
///
/// Requires a [GameSession] and the current [roundNumber].
class RoundView extends StatefulWidget {
final GameSession gameSession;
final int roundNumber;
const RoundView(
{super.key, required this.roundNumber, required this.gameSession});
@override
// ignore: library_private_types_in_public_api
_RoundViewState createState() => _RoundViewState();
}
class _RoundViewState extends State<RoundView> {
/// The current game session.
late GameSession gameSession = widget.gameSession;
/// Index of the player who said CABO.
int _caboPlayerIndex = 0;
/// Index of the player who has Kamikaze.
/// Default is null (no Kamikaze player).
int? _kamikazePlayerIndex;
/// List of text controllers for the score text fields.
late final List<TextEditingController> _scoreControllerList = List.generate(
widget.gameSession.players.length,
(index) => TextEditingController(),
);
/// List of focus nodes for the score text fields.
late final List<FocusNode> _focusNodeList = List.generate(
widget.gameSession.players.length,
(index) => FocusNode(),
);
late List<GlobalKey> _textFieldKeys;
@override
void initState() {
print('=== Runde ${widget.roundNumber} geöffnet ===');
if (widget.roundNumber < widget.gameSession.roundNumber ||
widget.gameSession.isGameFinished == true) {
print(
'Diese wurde bereits gespielt, deshalb werden die alten Punktestaende angezeigt');
// If the current round has already been played, the text fields
// are filled with the scores from this round
for (int i = 0; i < _scoreControllerList.length; i++) {
_scoreControllerList[i].text =
gameSession.roundList[widget.roundNumber - 1].scores[i].toString();
}
_caboPlayerIndex =
gameSession.roundList[widget.roundNumber - 1].caboPlayerIndex;
_kamikazePlayerIndex =
gameSession.roundList[widget.roundNumber - 1].kamikazePlayerIndex;
}
_textFieldKeys = List.generate(
widget.gameSession.players.length,
(index) => GlobalKey(),
);
super.initState();
}
@override
@override
Widget build(BuildContext context) {
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
final rotatedPlayers = _getRotatedPlayers();
final originalIndices = _getOriginalIndices();
return CupertinoPageScaffold(
resizeToAvoidBottomInset: false,
navigationBar: CupertinoNavigationBar(
leading: CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () => {
LocalStorageService.saveGameSessions(),
Navigator.pop(context, -1)
},
child: Text(AppLocalizations.of(context).cancel),
),
middle: Text(AppLocalizations.of(context).results),
trailing: Visibility(
visible: widget.gameSession.isGameFinished,
child: const Icon(
CupertinoIcons.lock,
size: 25,
))),
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: EdgeInsets.only(bottom: 20 + bottomInset),
child: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 40),
Text(
'${AppLocalizations.of(context).round} ${widget.roundNumber}',
style: CustomTheme.roundTitle),
const SizedBox(height: 10),
Text(
AppLocalizations.of(context).who_said_cabo,
style: const TextStyle(fontWeight: FontWeight.bold),
),
Padding(
padding: EdgeInsets.symmetric(
horizontal:
widget.gameSession.players.length > 3 ? 5 : 20,
vertical: 10,
),
child: SizedBox(
height: 60,
child: CupertinoSegmentedControl<int>(
unselectedColor:
CustomTheme.mainElementBackgroundColor,
selectedColor: CustomTheme.primaryColor,
groupValue: _caboPlayerIndex,
children: Map.fromEntries(widget.gameSession.players
.asMap()
.entries
.map((entry) {
final index = entry.key;
final name = entry.value;
return MapEntry(
index,
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 8,
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
name,
textAlign: TextAlign.center,
maxLines: 1,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
),
),
);
})),
onValueChanged: (value) {
setState(() {
_caboPlayerIndex = value;
});
},
),
),
),
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: rotatedPlayers.length,
itemBuilder: (context, index) {
final originalIndex = originalIndices[index];
final name = rotatedPlayers[index];
bool shouldShowMedal =
index == 0 && widget.roundNumber > 1;
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 10, horizontal: 20),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CupertinoListTile(
backgroundColor: CustomTheme.playerTileColor,
title: Row(children: [
Expanded(
child: Row(children: [
Text(
name,
overflow: TextOverflow.ellipsis,
),
Visibility(
visible: shouldShowMedal,
child: const SizedBox(width: 10),
),
Visibility(
visible: shouldShowMedal,
child: const Icon(FontAwesomeIcons.crown,
size: 15))
]))
]),
subtitle: Text(
'${widget.gameSession.playerScores[originalIndex]}'
' ${AppLocalizations.of(context).points}'),
trailing: SizedBox(
width: 100,
key: _textFieldKeys[originalIndex],
child: CupertinoTextField(
maxLength: 3,
focusNode: _focusNodeList[originalIndex],
keyboardType:
const TextInputType.numberWithOptions(
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(() {}),
),
),
),
),
);
},
),
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: TextStyle(
color: CustomTheme.kamikazeColor,
)),
),
),
),
],
),
),
),
),
KeyboardVisibilityBuilder(
builder: (context, visible) {
if (!visible) {
return Container(
height: 80,
padding: const EdgeInsets.only(bottom: 20),
color: CustomTheme.mainElementBackgroundColor,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
CupertinoButton(
onPressed: _areRoundInputsValid()
? () {
_endOfRoundNavigation(context, false);
}
: null,
child: Text(AppLocalizations.of(context).done),
),
if (!widget.gameSession.isGameFinished)
CupertinoButton(
onPressed: _areRoundInputsValid()
? () {
_endOfRoundNavigation(context, true);
}
: null,
child: Text(AppLocalizations.of(context).next_round),
),
],
),
);
} else {
return const SizedBox.shrink();
}
},
),
],
),
);
}
/// Gets the index of the player who won the previous round.
/// Returns 0 in the first round, as there is no previous round.
int _getPreviousRoundWinnerIndex() {
if (widget.roundNumber == 1) {
return 0; // If it's the first round, the order should be the same as the players list.
}
final List<int> scores =
widget.gameSession.roundList[widget.roundNumber - 2].scoreUpdates;
final int winnerIndex = scores.indexOf(0);
// Fallback if no player has 0 points, which should not happen in a valid game.
if (winnerIndex == -1) {
return 0;
}
return winnerIndex;
}
/// Rotates the players list based on the previous round's winner.
List<String> _getRotatedPlayers() {
final winnerIndex = _getPreviousRoundWinnerIndex();
return [
widget.gameSession.players[winnerIndex],
...widget.gameSession.players.sublist(winnerIndex + 1),
...widget.gameSession.players.sublist(0, winnerIndex)
];
}
/// Gets the original indices of the players by recalculating it from the rotated list.
List<int> _getOriginalIndices() {
final winnerIndex = _getPreviousRoundWinnerIndex();
return [
winnerIndex,
...List.generate(widget.gameSession.players.length - winnerIndex - 1,
(i) => winnerIndex + i + 1),
...List.generate(winnerIndex, (i) => i)
];
}
/// 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,
style: TextStyle(color: CustomTheme.kamikazeColor),
),
);
}).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.
/// [index] is the index of the current text field.
void _focusNextTextfield(int index) {
final originalIndices = _getOriginalIndices();
final currentPos = originalIndices.indexOf(index);
if (currentPos < originalIndices.length - 1) {
final nextIndex = originalIndices[currentPos + 1];
FocusScope.of(context)
.requestFocus(_focusNodeList[originalIndices[currentPos + 1]]);
final scrollContext = _textFieldKeys[nextIndex].currentContext;
if (scrollContext != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
Scrollable.ensureVisible(
scrollContext,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
alignment: 0.55,
);
});
}
} else {
_focusNodeList[index].unfocus();
}
}
/// Checks if the inputs for the round are valid.
/// Returns true if the inputs are valid, false otherwise.
/// Round Inputs are valid if every player has a score or
/// kamikaze is selected for a player
bool _areRoundInputsValid() {
if (_areTextFieldsEmpty() && _kamikazePlayerIndex == null) return false;
return true;
}
/// Checks if any of the text fields for the players points are empty.
/// Returns true if any of the text fields is empty, false otherwise.
bool _areTextFieldsEmpty() {
for (TextEditingController t in _scoreControllerList) {
if (t.text.isEmpty) {
return true;
}
}
return false;
}
/// Finishes the current round.
/// It first determines, ifCalls the [_calculateScoredPoints()] method to calculate the points for
/// every player. If the round is the highest round played in this game,
/// it expands the player score lists. At the end it updates the score
/// array for the game.
List<int> _finishRound() {
print('====================================');
print('Runde ${widget.roundNumber} beendet');
// The shown round is smaller than the newest round
if (widget.roundNumber < widget.gameSession.roundNumber) {
print('Da diese Runde bereits gespielt wurde, werden die alten '
'Punktestaende ueberschrieben');
}
if (_kamikazePlayerIndex != null) {
print('${widget.gameSession.players[_kamikazePlayerIndex!]} hat Kamikaze '
'und bekommt 0 Punkte');
print('Alle anderen Spieler bekommen 50 Punkte');
widget.gameSession
.applyKamikaze(widget.roundNumber, _kamikazePlayerIndex!);
} else {
List<int> roundScores = [];
for (TextEditingController c in _scoreControllerList) {
if (c.text.isNotEmpty) roundScores.add(int.parse(c.text));
}
widget.gameSession.calculateScoredPoints(
widget.roundNumber, roundScores, _caboPlayerIndex);
}
List<int> bonusPlayers = widget.gameSession.updatePoints();
if (widget.gameSession.isGameFinished == true) {
print('Das Spiel ist beendet');
} else if (widget.roundNumber == widget.gameSession.roundNumber) {
widget.gameSession.increaseRound();
}
return bonusPlayers;
}
/// Shows a popup dialog with the information which player received the bonus points.
Future<void> _showBonusPopup(
BuildContext context, List<int> bonusPlayers) async {
int pointLimit = widget.gameSession.pointLimit;
int bonusPoints = (pointLimit / 2).round();
String resultText =
_getBonusPopupMessageString(pointLimit, bonusPoints, bonusPlayers);
await showCupertinoDialog(
context: context,
builder: (context) => CupertinoAlertDialog(
title: Text(AppLocalizations.of(context).bonus_points_title),
content: Text(resultText),
actions: [
CupertinoDialogAction(
child: Text(AppLocalizations.of(context).ok),
onPressed: () => Navigator.of(context).pop(),
),
],
),
);
}
/// Generates the message string for the bonus popup.
/// It takes the [pointLimit], [bonusPoints] and the list of [bonusPlayers]
/// and returns a formatted string.
String _getBonusPopupMessageString(
int pointLimit, int bonusPoints, List<int> bonusPlayers) {
List<String> nameList =
bonusPlayers.map((i) => widget.gameSession.players[i]).toList();
String resultText = '';
if (nameList.length == 1) {
resultText = AppLocalizations.of(context).bonus_points_message(
nameList.length, nameList.first, pointLimit, bonusPoints);
} else {
resultText = nameList.length == 2
? '${nameList[0]} & ${nameList[1]}'
: '${nameList.sublist(0, nameList.length - 1).join(', ')} & ${nameList.last}';
resultText = AppLocalizations.of(context).bonus_points_message(
nameList.length,
resultText,
pointLimit,
bonusPoints,
);
}
return resultText;
}
/// 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
void dispose() {
for (final controller in _scoreControllerList) {
controller.dispose();
}
for (final focusNode in _focusNodeList) {
focusNode.dispose();
}
super.dispose();
}
}

View File

@@ -0,0 +1,504 @@
import 'package:cabo_counter/core/constants.dart';
import 'package:cabo_counter/core/custom_theme.dart';
import 'package:cabo_counter/data/game_manager.dart';
import 'package:cabo_counter/data/game_session.dart';
import 'package:cabo_counter/l10n/generated/app_localizations.dart';
import 'package:cabo_counter/presentation/views/home/active_game/active_game_view.dart';
import 'package:cabo_counter/presentation/views/home/active_game/mode_selection_view.dart';
import 'package:cabo_counter/presentation/widgets/custom_button.dart';
import 'package:cabo_counter/services/config_service.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 {
noGameTitle,
noModeSelected,
minPlayers,
maxPlayers,
noPlayerName,
}
/// A view for creating a new game session in the Cabo Counter app.
///
/// The [CreateGameView] allows users to input a game title, select a game mode,
/// add and reorder player names, and validate all required fields before
/// starting a new game. It provides feedback dialogs for missing or invalid
/// input and navigates to the active game view upon successful creation.
class CreateGameView extends StatefulWidget {
final GameMode gameMode;
final String? gameTitle;
final List<String>? players;
const CreateGameView({
super.key,
this.gameTitle,
this.players,
required this.gameMode,
});
@override
// ignore: library_private_types_in_public_api
_CreateGameViewState createState() => _CreateGameViewState();
}
class _CreateGameViewState extends State<CreateGameView> {
final TextEditingController _gameTitleTextController =
TextEditingController();
/// List of text controllers for player names.
final List<TextEditingController> _playerNameTextControllers = [
TextEditingController()
];
/// List of focus nodes for player name text fields.
final List<FocusNode> _playerNameFocusNodes = [FocusNode()];
/// Maximum number of players allowed in the game.
final int maxPlayers = 5;
/// Factor to adjust the view length when the keyboard is visible.
final double keyboardHeightAdjustmentFactor = 0.75;
/// Variable to hold the selected game mode.
late GameMode gameMode;
@override
void initState() {
super.initState();
gameMode = widget.gameMode;
_gameTitleTextController.text = widget.gameTitle ?? '';
if (widget.players != null) {
_playerNameTextControllers.clear();
for (var player in widget.players!) {
_playerNameTextControllers.add(TextEditingController(text: player));
_playerNameFocusNodes.add(FocusNode());
}
}
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvokedWithResult: (bool didPop, dynamic result) async {
if (!didPop) {
await _keyboardDelay();
if (context.mounted) Navigator.pop(context);
}
},
child: CupertinoPageScaffold(
resizeToAvoidBottomInset: false,
navigationBar: CupertinoNavigationBar(
previousPageTitle: AppLocalizations.of(context).games,
middle: Text(AppLocalizations.of(context).new_game),
),
child: SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 0, 0),
child: Text(
AppLocalizations.of(context).game,
style: CustomTheme.rowTitle,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(15, 10, 10, 0),
child: CupertinoTextField(
decoration: const BoxDecoration(),
maxLength: 20,
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(
padding: const EdgeInsets.fromLTRB(15, 10, 10, 0),
child: CupertinoTextField(
decoration: const BoxDecoration(),
readOnly: true,
prefix: Text(AppLocalizations.of(context).mode),
suffix: Row(
children: [
_getDisplayedGameMode(),
const SizedBox(width: 3),
const CupertinoListTileChevron(),
],
),
onTap: () async {
await _keyboardDelay();
if (context.mounted) {
final selectedMode = await Navigator.push(
context,
CupertinoPageRoute(
builder: (context) => ModeSelectionMenu(
pointLimit: ConfigService.getPointLimit(),
showDeselection: false,
),
),
);
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(
key: ValueKey(index),
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
CupertinoButton(
padding: EdgeInsets.zero,
child: Icon(
CupertinoIcons.minus_circle_fill,
color: CustomTheme.red,
size: 25,
),
onPressed: () {
setState(() {
_playerNameTextControllers[index].dispose();
_playerNameTextControllers.removeAt(index);
});
},
),
Expanded(
child: CupertinoTextField(
controller: _playerNameTextControllers[index],
focusNode: _playerNameFocusNodes[index],
maxLength: 12,
placeholder:
'${AppLocalizations.of(context).player} ${index + 1}',
padding: const EdgeInsets.all(12),
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.kFadeInDuration),
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(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
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);
}
},
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(0, 0, 0, 50),
child: Center(
child: CustomButton(
child: Text(
AppLocalizations.of(context).create_game,
style: TextStyle(
color: CustomTheme.primaryColor,
),
),
onPressed: () async {
await _keyboardDelay();
_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].
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
);
}
}
/// Creates a new gameSession and navigates to the active game view.
/// This method creates a new gameSession object with the provided attributes in the text fields.
/// 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) {
players.add(controller.text);
}
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.pushAndRemoveUntil(
context,
CupertinoPageRoute(
builder: (context) => ActiveGameView(gameSession: session)),
(Route<dynamic> route) => route.isFirst,
);
}
/// If the keyboard is visible, this method will unfocus the current text field
/// to prevent the keyboard from interfering with the navigation bar.
Future<void> _keyboardDelay() async {
if (!KeyboardVisibilityController().isVisible) {
return;
} else {
FocusScope.of(context).unfocus();
await Future.delayed(
const Duration(milliseconds: Constants.kKeyboardDelay));
}
}
@override
void dispose() {
_gameTitleTextController.dispose();
for (var controller in _playerNameTextControllers) {
controller.dispose();
}
for (var focusnode in _playerNameFocusNodes) {
focusnode.dispose();
}
super.dispose();
}
}

View File

@@ -0,0 +1,379 @@
import 'package:cabo_counter/core/constants.dart';
import 'package:cabo_counter/core/custom_theme.dart';
import 'package:cabo_counter/data/game_manager.dart';
import 'package:cabo_counter/data/game_session.dart';
import 'package:cabo_counter/l10n/generated/app_localizations.dart';
import 'package:cabo_counter/presentation/views/home/active_game/active_game_view.dart';
import 'package:cabo_counter/presentation/views/home/create_game_view.dart';
import 'package:cabo_counter/presentation/views/home/settings_view.dart';
import 'package:cabo_counter/services/config_service.dart';
import 'package:cabo_counter/services/local_storage_service.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
enum PreRatingDialogDecision { yes, no, cancel }
enum BadRatingDialogDecision { email, cancel }
/// Home screen of the app that displays a list of game sessions.
///
/// The [MainMenuView] is the main entry point for the app's home screen.
/// It displays a list of existing game sessions, allows users to create new games,
/// access settings, and handles user feedback dialogs for app rating and support.
class MainMenuView extends StatefulWidget {
const MainMenuView({super.key});
@override
// ignore: library_private_types_in_public_api
_MainMenuViewState createState() => _MainMenuViewState();
}
class _MainMenuViewState extends State<MainMenuView> {
bool _isLoading = true;
@override
initState() {
super.initState();
LocalStorageService.loadGameSessions().then((_) {
setState(() {
_isLoading = false;
});
});
gameManager.addListener(_updateView);
WidgetsBinding.instance.addPostFrameCallback((_) async {
precacheImage(
const AssetImage('assets/cabo_counter-logo_rounded.png'), context);
await Constants.rateMyApp.init();
if (Constants.rateMyApp.shouldOpenDialog &&
Constants.appDevPhase != 'Beta') {
await Future.delayed(const Duration(milliseconds: 600));
if (!mounted) return;
_handleFeedbackDialog(context);
}
});
}
void _updateView() {
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: gameManager,
builder: (context, _) {
return CupertinoPageScaffold(
resizeToAvoidBottomInset: false,
navigationBar: CupertinoNavigationBar(
leading: IconButton(
onPressed: () {
Navigator.push(
context,
CupertinoPageRoute(
builder: (context) => const SettingsView(),
),
).then((_) {
setState(() {});
});
},
icon: const Icon(CupertinoIcons.settings, size: 30)),
middle: Text(AppLocalizations.of(context).games),
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,
),
itemBuilder: (context, index) {
final session = gameManager.gameList[index];
return ListenableBuilder(
listenable: session,
builder: (context, _) {
return Dismissible(
key: Key(session.id),
background: Container(
color: CustomTheme.red,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20.0),
child: const Icon(
CupertinoIcons.delete,
color: CupertinoColors.white,
),
),
direction: DismissDirection.endToStart,
confirmDismiss: (direction) async {
return await _showDeleteGamePopup(
context, session.gameTitle);
},
onDismissed: (direction) {
gameManager.removeGameSessionById(session.id);
},
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: Visibility(
visible: session.isGameFinished,
replacement: Text(
'${AppLocalizations.of(context).mode}: ${_translateGameMode(session)}',
style: const TextStyle(fontSize: 14),
),
child: Text(
'\u{1F947} ${session.winner}',
style: const TextStyle(fontSize: 14),
)),
trailing: Row(
children: [
const SizedBox(
width: 5,
),
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.
/// If [pointLimit] is true, it returns '101 Punkte', otherwise it returns 'Unbegrenzt'.
String _translateGameMode(GameSession gameSession) {
if (gameSession.isPointsLimitEnabled) {
return '${gameSession.pointLimit} ${AppLocalizations.of(context).points}';
}
return AppLocalizations.of(context).unlimited;
}
/// Handles the feedback dialog when the conditions for rating are met.
/// It shows a dialog asking the user if they like the app,
/// and based on their response, it either opens the rating dialog or an email client for feedback.
Future<void> _handleFeedbackDialog(BuildContext context) async {
final String emailSubject = AppLocalizations.of(context).email_subject;
final String emailBody = AppLocalizations.of(context).email_body;
final Uri emailUri = Uri(
scheme: 'mailto',
path: Constants.kEmail,
query: 'subject=$emailSubject'
'&body=$emailBody',
);
PreRatingDialogDecision preRatingDecision =
await _showPreRatingDialog(context);
BadRatingDialogDecision badRatingDecision = BadRatingDialogDecision.cancel;
// so that the bad rating dialog is not shown immediately
await Future.delayed(const Duration(milliseconds: Constants.kPopUpDelay));
switch (preRatingDecision) {
case PreRatingDialogDecision.yes:
if (context.mounted) Constants.rateMyApp.showStarRateDialog(context);
break;
case PreRatingDialogDecision.no:
if (context.mounted) {
badRatingDecision = await _showBadRatingDialog(context);
}
if (badRatingDecision == BadRatingDialogDecision.email) {
if (context.mounted) {
launchUrl(emailUri);
}
}
break;
case PreRatingDialogDecision.cancel:
break;
}
}
/// Shows a confirmation dialog to delete all game sessions.
/// Returns true if the user confirms the deletion, false otherwise.
/// [gameTitle] is the title of the game session to be deleted.
Future<bool> _showDeleteGamePopup(
BuildContext context, String gameTitle) 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(gameTitle)),
actions: [
CupertinoDialogAction(
onPressed: () {
Navigator.of(context).pop(false);
},
child: Text(AppLocalizations.of(context).cancel),
),
CupertinoDialogAction(
isDestructiveAction: true,
isDefaultAction: true,
onPressed: () {
Navigator.of(context).pop(true);
},
child: Text(
AppLocalizations.of(context).delete,
),
)
]);
},
) ??
false;
}
/// Shows a dialog asking the user if they like the app.
/// Returns the user's decision as an integer.
/// - PRE_RATING_DIALOG_YES: User likes the app and wants to rate it.
/// - PRE_RATING_DIALOG_NO: User does not like the app and wants to provide feedback.
/// - PRE_RATING_DIALOG_CANCEL: User cancels the dialog.
Future<PreRatingDialogDecision> _showPreRatingDialog(
BuildContext context) async {
return await showCupertinoDialog<PreRatingDialogDecision>(
context: context,
builder: (BuildContext context) => CupertinoAlertDialog(
title: Text(AppLocalizations.of(context).pre_rating_title),
content:
Text(AppLocalizations.of(context).pre_rating_message),
actions: [
CupertinoDialogAction(
onPressed: () => Navigator.of(context)
.pop(PreRatingDialogDecision.yes),
isDefaultAction: true,
child: Text(AppLocalizations.of(context).yes),
),
CupertinoDialogAction(
onPressed: () =>
Navigator.of(context).pop(PreRatingDialogDecision.no),
child: Text(AppLocalizations.of(context).no),
),
CupertinoDialogAction(
onPressed: () => Navigator.of(context).pop(),
isDestructiveAction: true,
child: Text(AppLocalizations.of(context).cancel),
)
],
)) ??
PreRatingDialogDecision.cancel;
}
/// Shows a dialog asking the user for feedback if they do not like the app.
/// Returns the user's decision as an integer.
/// - BAD_RATING_DIALOG_EMAIL: User wants to send an email with feedback.
/// - BAD_RATING_DIALOG_CANCEL: User cancels the dialog.
Future<BadRatingDialogDecision> _showBadRatingDialog(
BuildContext context) async {
return await showCupertinoDialog<BadRatingDialogDecision>(
context: context,
builder: (BuildContext context) => CupertinoAlertDialog(
title: Text(AppLocalizations.of(context).bad_rating_title),
content:
Text(AppLocalizations.of(context).bad_rating_message),
actions: [
CupertinoDialogAction(
isDefaultAction: true,
onPressed: () => Navigator.of(context)
.pop(BadRatingDialogDecision.email),
child: Text(AppLocalizations.of(context).contact_email),
),
CupertinoDialogAction(
isDestructiveAction: true,
onPressed: () => Navigator.of(context).pop(),
child: Text(AppLocalizations.of(context).cancel))
],
)) ??
BadRatingDialogDecision.cancel;
}
@override
void dispose() {
gameManager.removeListener(_updateView);
super.dispose();
}
}

View File

@@ -0,0 +1,302 @@
import 'package:cabo_counter/core/constants.dart';
import 'package:cabo_counter/core/custom_theme.dart';
import 'package:cabo_counter/l10n/generated/app_localizations.dart';
import 'package:cabo_counter/presentation/views/home/active_game/mode_selection_view.dart';
import 'package:cabo_counter/presentation/widgets/custom_form_row.dart';
import 'package:cabo_counter/presentation/widgets/custom_stepper.dart';
import 'package:cabo_counter/services/config_service.dart';
import 'package:cabo_counter/services/local_storage_service.dart';
import 'package:cabo_counter/services/version_service.dart';
import 'package:flutter/cupertino.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:url_launcher/url_launcher.dart';
/// Settings and information page for the app.
///
/// [SettingsView] is a settings page for the app, allowing users to configure game options,
/// manage game data (import, export, delete), and view app information.
class SettingsView extends StatefulWidget {
const SettingsView({super.key});
@override
State<SettingsView> createState() => _SettingsViewState();
}
class _SettingsViewState extends State<SettingsView> {
UniqueKey _stepperKey1 = UniqueKey();
UniqueKey _stepperKey2 = UniqueKey();
GameMode defaultMode = ConfigService.getGameMode();
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(AppLocalizations.of(context).settings),
previousPageTitle: AppLocalizations.of(context).games,
),
child: SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 0, 0),
child: Text(
AppLocalizations.of(context).points,
style: CustomTheme.rowTitle,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(10, 15, 10, 10),
child: CupertinoFormSection.insetGrouped(
backgroundColor: CustomTheme.backgroundColor,
margin: EdgeInsets.zero,
children: [
CustomFormRow(
prefixText: AppLocalizations.of(context).cabo_penalty,
prefixIcon: CupertinoIcons.bolt_fill,
suffixWidget: CustomStepper(
key: _stepperKey1,
initialValue: ConfigService.getCaboPenalty(),
minValue: 0,
maxValue: 50,
step: 1,
onChanged: (newCaboPenalty) {
setState(() {
ConfigService.setCaboPenalty(newCaboPenalty);
});
},
),
),
CustomFormRow(
prefixText: AppLocalizations.of(context).point_limit,
prefixIcon: FontAwesomeIcons.bullseye,
suffixWidget: CustomStepper(
key: _stepperKey2,
initialValue: ConfigService.getPointLimit(),
minValue: 30,
maxValue: 1000,
step: 10,
onChanged: (newPointLimit) {
setState(() {
ConfigService.setPointLimit(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(
prefixText:
AppLocalizations.of(context).reset_to_default,
prefixIcon: CupertinoIcons.arrow_counterclockwise,
onPressed: () {
ConfigService.resetConfig();
setState(() {
_stepperKey1 = UniqueKey();
_stepperKey2 = UniqueKey();
defaultMode = ConfigService.getGameMode();
});
},
)
])),
Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 0, 0),
child: Text(
AppLocalizations.of(context).game_data,
style: CustomTheme.rowTitle,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(10, 15, 10, 10),
child: CupertinoFormSection.insetGrouped(
backgroundColor: CustomTheme.backgroundColor,
margin: EdgeInsets.zero,
children: [
CustomFormRow(
prefixText: AppLocalizations.of(context).import_data,
prefixIcon: CupertinoIcons.square_arrow_down,
onPressed: () async {
final status =
await LocalStorageService.importJsonFile();
showFeedbackDialog(status);
},
suffixWidget: const CupertinoListTileChevron(),
),
CustomFormRow(
prefixText: AppLocalizations.of(context).export_data,
prefixIcon: CupertinoIcons.square_arrow_up,
onPressed: () => LocalStorageService.exportGameData(),
suffixWidget: const CupertinoListTileChevron(),
),
CustomFormRow(
prefixText: AppLocalizations.of(context).delete_data,
prefixIcon: CupertinoIcons.trash,
onPressed: () => _deleteAllGames(),
),
])),
Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 0, 0),
child: Text(
AppLocalizations.of(context).app,
style: CustomTheme.rowTitle,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(10, 15, 10, 0),
child: CupertinoFormSection.insetGrouped(
backgroundColor: CustomTheme.backgroundColor,
margin: EdgeInsets.zero,
children: [
CustomFormRow(
prefixText: AppLocalizations.of(context).wiki,
prefixIcon: CupertinoIcons.book,
onPressed: () =>
launchUrl(Uri.parse(Constants.kGithubWikiLink)),
suffixWidget: const CupertinoListTileChevron(),
),
CustomFormRow(
prefixText: AppLocalizations.of(context).error_found,
prefixIcon: FontAwesomeIcons.github,
onPressed: () =>
launchUrl(Uri.parse(Constants.kGithubIssuesLink)),
suffixWidget: const CupertinoListTileChevron(),
),
CustomFormRow(
prefixText: AppLocalizations.of(context).app_version,
prefixIcon: CupertinoIcons.tag,
onPressed: null,
suffixWidget: Text(VersionService.getVersion(),
style: TextStyle(
color: CustomTheme.primaryColor,
))),
CustomFormRow(
prefixText: AppLocalizations.of(context).build,
prefixIcon: CupertinoIcons.number,
onPressed: null,
suffixWidget: Text(VersionService.getBuildNumber(),
style: TextStyle(
color: CustomTheme.primaryColor,
))),
])),
const SizedBox(height: 50)
],
),
)),
);
}
/// Shows a dialog to confirm the deletion of all game data.
/// When confirmed, it deletes all game data from local storage.
void _deleteAllGames() {
showCupertinoDialog(
context: context,
builder: (context) {
return CupertinoAlertDialog(
title: Text(AppLocalizations.of(context).delete_data_title),
content: Text(AppLocalizations.of(context).delete_data_message),
actions: [
CupertinoDialogAction(
child: Text(AppLocalizations.of(context).cancel),
onPressed: () => Navigator.pop(context),
),
CupertinoDialogAction(
isDestructiveAction: true,
isDefaultAction: true,
child: Text(AppLocalizations.of(context).delete),
onPressed: () {
LocalStorageService.deleteAllGames();
Navigator.pop(context);
},
),
],
);
},
);
}
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

@@ -1,9 +1,18 @@
import 'package:cabo_counter/l10n/app_localizations.dart';
import 'package:cabo_counter/utility/custom_theme.dart';
import 'package:cabo_counter/views/information_view.dart';
import 'package:cabo_counter/views/main_menu_view.dart';
import 'package:cabo_counter/core/custom_theme.dart';
import 'package:cabo_counter/l10n/generated/app_localizations.dart';
import 'package:cabo_counter/presentation/views/about/about_view.dart';
import 'package:cabo_counter/presentation/views/home/main_menu_view.dart';
import 'package:flutter/cupertino.dart';
/// TabBar for navigating between the main menu and about section.
///
/// [TabView] is a [StatefulWidget] that provides a tabbed interface for navigating
/// between the main menu and the about section of the app. It uses a
/// [CupertinoTabScaffold] with two tabs:
/// - Home (MainMenuView)
/// - About (AboutView)
///
/// The tab labels are provided via localization.
class TabView extends StatefulWidget {
const TabView({super.key});
@@ -16,8 +25,9 @@ class _TabViewState extends State<TabView> {
@override
Widget build(BuildContext context) {
return CupertinoTabScaffold(
resizeToAvoidBottomInset: false,
tabBar: CupertinoTabBar(
backgroundColor: CustomTheme.backgroundTintColor,
backgroundColor: CustomTheme.mainElementBackgroundColor,
iconSize: 27,
height: 55,
items: <BottomNavigationBarItem>[
@@ -39,7 +49,7 @@ class _TabViewState extends State<TabView> {
if (index == 0) {
return const MainMenuView();
} else {
return const InformationView();
return const AboutView();
}
});
},

View File

@@ -0,0 +1,23 @@
import 'package:cabo_counter/core/custom_theme.dart';
import 'package:flutter/cupertino.dart';
/// A customizable button widget using Cupertino style.
///
/// Displays a button with a child widget and optional callback.
/// The button uses a medium size, rounded corners, and a custom background color.
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

@@ -0,0 +1,58 @@
import 'package:cabo_counter/core/custom_theme.dart';
import 'package:cabo_counter/presentation/widgets/custom_stepper.dart';
import 'package:flutter/cupertino.dart';
/// A customizable form row widget with a prefix icon, text, and optional suffix widget.
///
/// Displays a row with an icon and text on the left side.
/// Optionally, a suffix widget (e.g. a stepper) can be shown on the right side.
/// The row is styled as a [CupertinoButton] and can react to taps.
class CustomFormRow extends StatefulWidget {
final String prefixText;
final IconData prefixIcon;
final Widget? suffixWidget;
final void Function()? onPressed;
const CustomFormRow({
super.key,
required this.prefixText,
required this.prefixIcon,
this.onPressed,
this.suffixWidget,
});
@override
State<CustomFormRow> createState() => _CustomFormRowState();
}
class _CustomFormRowState extends State<CustomFormRow> {
late Widget suffixWidget;
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
suffixWidget = widget.suffixWidget ?? const SizedBox.shrink();
return CupertinoButton(
padding: EdgeInsets.zero,
onPressed: widget.onPressed,
child: CupertinoFormRow(
prefix: Row(
children: [
Icon(
widget.prefixIcon,
color: CustomTheme.primaryColor,
),
const SizedBox(width: 10),
Text(widget.prefixText),
],
),
padding: suffixWidget is CustomStepper
? const EdgeInsets.fromLTRB(15, 0, 0, 0)
: const EdgeInsets.symmetric(vertical: 10, horizontal: 15),
child: suffixWidget,
),
);
}
}

View File

@@ -1,12 +1,24 @@
import 'package:cabo_counter/core/custom_theme.dart';
import 'package:flutter/cupertino.dart'; // Für iOS-Style
class Stepper extends StatefulWidget {
/// A custom stepper widget for incrementing and decrementing a value.
///
/// The [CustomStepper] widget allows increasing and decreasing a value
/// within a defined range ([minValue] to [maxValue]) in fixed steps.
///
/// Properties:
/// - [minValue]: The minimum value.
/// - [maxValue]: The maximum value.
/// - [initialValue]: The initial value (optional, defaults to [minValue]).
/// - [step]: The step size.
/// - [onChanged]: Callback triggered when the value changes.
class CustomStepper extends StatefulWidget {
final int minValue;
final int maxValue;
final int? initialValue;
final int step;
final ValueChanged<int> onChanged;
const Stepper({
const CustomStepper({
super.key,
required this.minValue,
required this.maxValue,
@@ -17,10 +29,10 @@ class Stepper extends StatefulWidget {
@override
// ignore: library_private_types_in_public_api
_StepperState createState() => _StepperState();
_CustomStepperState createState() => _CustomStepperState();
}
class _StepperState extends State<Stepper> {
class _CustomStepperState extends State<CustomStepper> {
late int _value;
@override
@@ -34,18 +46,20 @@ class _StepperState extends State<Stepper> {
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [
CupertinoButton(
padding: const EdgeInsets.all(8),
padding: EdgeInsets.zero,
onPressed: _decrement,
child: const Icon(CupertinoIcons.minus),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Text('$_value', style: const TextStyle(fontSize: 18)),
child: Text('$_value',
style: TextStyle(fontSize: 18, color: CustomTheme.white)),
),
CupertinoButton(
padding: const EdgeInsets.all(8),
padding: EdgeInsets.zero,
onPressed: _increment,
child: const Icon(CupertinoIcons.add),
),

View File

@@ -1,55 +1,109 @@
import 'package:cabo_counter/utility/globals.dart';
import 'package:cabo_counter/presentation/views/home/active_game/mode_selection_view.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// This class handles the configuration settings for the app.
/// It uses SharedPreferences to store and retrieve the personal configuration of the app.
/// Currently it provides methods to initialize, get, and set the point limit and cabo penalty.
/// A service class for managing and persisting app configuration settings using `SharedPreferences`.
///
/// Provides methods to initialize, retrieve, update, and reset configuration values such as point limit,
/// cabo penalty, and game mode. Ensures that user preferences are stored locally and persist across app restarts.
class ConfigService {
// Keys for the stored values
static const String _keyPointLimit = 'pointLimit';
static const String _keyCaboPenalty = 'caboPenalty';
static const int _defaultPointLimit = 100; // Default Value
static const int _defaultCaboPenalty = 5; // Default Value
static const String _keyGameMode = 'gameMode';
// Actual values used in the app
static int _pointLimit = 100;
static int _caboPenalty = 5;
static int _gameMode = -1;
// Default values
static const int _defaultPointLimit = 100;
static const int _defaultCaboPenalty = 5;
static const int _defaultGameMode = -1;
static Future<void> initConfig() async {
final prefs = await SharedPreferences.getInstance();
// Default values only set if they are not already set
prefs.setInt(
_keyPointLimit, prefs.getInt(_keyPointLimit) ?? _defaultPointLimit);
prefs.setInt(
_keyCaboPenalty, prefs.getInt(_keyCaboPenalty) ?? _defaultCaboPenalty);
// Initialize pointLimit, caboPenalty, and gameMode from SharedPreferences
// If they are not set, use the default values
_pointLimit = prefs.getInt(_keyPointLimit) ?? _defaultPointLimit;
_caboPenalty = 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.
static Future<int> getPointLimit() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getInt(_keyPointLimit) ?? _defaultPointLimit;
/// Retrieves the current game mode.
///
/// The game mode is determined based on the stored integer value:
/// - `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.
/// [newPointLimit] is the new point limit to be set.
static Future<void> setPointLimit(int newPointLimit) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_keyPointLimit, newPointLimit);
_pointLimit = newPointLimit;
}
/// Getter for the cabo penalty.
static Future<int> getCaboPenalty() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getInt(_keyCaboPenalty) ?? _defaultCaboPenalty;
}
static int getCaboPenalty() => _caboPenalty;
/// Setter for the cabo penalty.
/// [newCaboPenalty] is the new cabo penalty to be set.
static Future<void> setCaboPenalty(int newCaboPenalty) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_keyCaboPenalty, newCaboPenalty);
_caboPenalty = newCaboPenalty;
}
/// Resets the configuration to default values.
static Future<void> resetConfig() async {
Globals.pointLimit = _defaultPointLimit;
Globals.caboPenalty = _defaultCaboPenalty;
ConfigService._pointLimit = _defaultPointLimit;
ConfigService._caboPenalty = _defaultCaboPenalty;
ConfigService._gameMode = _defaultGameMode;
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.');
@@ -47,6 +55,7 @@ class LocalStorageService {
try {
final file = await _getFilePath();
// Check if the file exists
if (!await file.exists()) {
print(
'[local_storage_service.dart] Es existiert noch keine Datei mit Spieldaten');
@@ -57,12 +66,14 @@ class LocalStorageService {
'[local_storage_service.dart] Es existiert bereits eine Datei mit Spieldaten');
final jsonString = await file.readAsString();
// Check if the file is empty
if (jsonString.isEmpty) {
print('[local_storage_service.dart] Die gefundene Datei ist leer');
return false;
}
if (!await validateJsonSchema(jsonString)) {
// Validate the JSON schema
if (!await _validateJsonSchema(jsonString, true)) {
print(
'[local_storage_service.dart] Die Datei konnte nicht validiert werden');
gameManager.gameList = [];
@@ -78,6 +89,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 +105,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 +134,82 @@ 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(
dialogTitle: 'Wähle eine Datei mit Spieldaten aus',
static Future<ImportStatus> importJsonFile() async {
final path = await FilePicker.platform.pickFiles(
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;
// Checks if the JSON String is in the gameList format
if (await _validateJsonSchema(jsonString, true)) {
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 +219,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

@@ -0,0 +1,32 @@
import 'package:cabo_counter/core/constants.dart';
import 'package:package_info_plus/package_info_plus.dart';
class VersionService {
static String _version = '-.-.-';
static String _buildNumber = '-';
static Future<void> init() async {
var packageInfo = await PackageInfo.fromPlatform();
_version = packageInfo.version;
_buildNumber = packageInfo.buildNumber;
}
static String getVersionNumber() {
return _version;
}
static String getVersion() {
if (_version == '-.-.-') {
return getVersionNumber();
}
return '${Constants.appDevPhase} $_version';
}
static String getBuildNumber() {
return _buildNumber;
}
static String getVersionWithBuild() {
return '$_version ($_buildNumber)';
}
}

View File

@@ -1,30 +0,0 @@
import 'package:flutter/cupertino.dart';
class CustomTheme {
static Color white = CupertinoColors.white;
static Color primaryColor = CupertinoColors.systemGreen;
static Color backgroundColor = const Color(0xFF101010);
static Color backgroundTintColor = CupertinoColors.darkBackgroundGray;
static TextStyle modeTitle = TextStyle(
color: primaryColor,
fontSize: 20,
fontWeight: FontWeight.bold,
);
static const TextStyle modeDescription = TextStyle(
fontSize: 16,
);
static TextStyle rowTitle = TextStyle(
fontSize: 20,
color: primaryColor,
fontWeight: FontWeight.bold,
);
static TextStyle roundTitle = TextStyle(
fontSize: 60,
color: white,
fontWeight: FontWeight.bold,
);
}

View File

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

View File

@@ -1,207 +0,0 @@
import 'package:cabo_counter/data/models/game_session.dart';
import 'package:cabo_counter/l10n/app_localizations.dart';
import 'package:cabo_counter/utility/custom_theme.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;
const ActiveGameView({super.key, required this.gameSession});
@override
// ignore: library_private_types_in_public_api
_ActiveGameViewState createState() => _ActiveGameViewState();
}
class _ActiveGameViewState extends State<ActiveGameView> {
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: widget.gameSession,
builder: (context, _) {
List<int> sortedPlayerIndices = _getSortedPlayerIndices();
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(widget.gameSession.gameTitle),
),
child: SafeArea(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 0, 0),
child: Text(
AppLocalizations.of(context).players,
style: CustomTheme.rowTitle,
),
),
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: widget.gameSession.players.length,
itemBuilder: (BuildContext context, int index) {
int playerIndex = sortedPlayerIndices[index];
return CupertinoListTile(
title: Row(
children: [
_getPlacementPrefix(index),
const SizedBox(width: 5),
Text(
widget.gameSession.players[playerIndex],
style: const TextStyle(
fontWeight: FontWeight.bold),
),
],
),
trailing: Row(
children: [
const SizedBox(width: 5),
Text(
'${widget.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: widget.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)),
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),
),
);
},
));
},
),
Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 0, 0),
child: Text(
AppLocalizations.of(context).game,
style: CustomTheme.rowTitle,
),
),
Column(
children: [
CupertinoListTile(
backgroundColorActivated:
CustomTheme.backgroundColor,
title: Text(
AppLocalizations.of(context).statistics,
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => GraphView(
gameSession: widget.gameSession,
)))),
CupertinoListTile(
title:
Text(AppLocalizations.of(context).delete_game,
style: const TextStyle(
color: Colors.white30,
)),
onTap: () {},
),
CupertinoListTile(
title: Text(
AppLocalizations.of(context)
.new_game_same_settings,
style: const TextStyle(
color: Colors.white30,
))),
CupertinoListTile(
title:
Text(AppLocalizations.of(context).export_game,
style: const TextStyle(
color: Colors.white30,
)),
),
],
)
],
),
),
));
});
}
/// 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);
// Sort the indices based on the summed points
playerIndices.sort((a, b) {
int scoreA = widget.gameSession.playerScores[a];
int scoreB = widget.gameSession.playerScores[b];
return scoreA.compareTo(scoreB);
});
return playerIndices;
}
/// Returns a widget that displays the placement prefix based on the index.
/// First three places are represented by medals, and the rest are numbered.
/// [index] is the index of the player in the descending sorted list.
Widget _getPlacementPrefix(int index) {
switch (index) {
case 0:
return const Text(
'\u{1F947}',
style: TextStyle(fontSize: 22),
);
case 1:
return const Text(
'\u{1F948}',
style: TextStyle(fontSize: 22),
);
case 2:
return const Text(
'\u{1F949}',
style: TextStyle(fontSize: 22),
);
default:
return Text(
' ${index + 1}.',
style: const TextStyle(fontWeight: FontWeight.bold),
);
}
}
}

View File

@@ -1,328 +0,0 @@
import 'package:cabo_counter/data/models/game_manager.dart';
import 'package:cabo_counter/data/models/game_session.dart';
import 'package:cabo_counter/l10n/app_localizations.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});
@override
// ignore: library_private_types_in_public_api
_CreateGameState createState() => _CreateGameState();
}
class _CreateGameState extends State<CreateGame> {
final List<TextEditingController> _playerNameTextControllers = [
TextEditingController()
];
final TextEditingController _gameTitleTextController =
TextEditingController();
/// Maximum number of players allowed in the game.
final int maxPlayers = 5;
/// Variable to store the selected game mode.
bool? selectedMode;
@override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
previousPageTitle: AppLocalizations.of(context).overview,
middle: Text(AppLocalizations.of(context).new_game),
),
child: SafeArea(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 0, 0),
child: Text(
AppLocalizations.of(context).game,
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(
selectedMode == null
? AppLocalizations.of(context).select_mode
: (selectedMode!
? '${Globals.pointLimit} ${AppLocalizations.of(context).points}'
: AppLocalizations.of(context).unlimited),
),
const SizedBox(width: 3),
const CupertinoListTileChevron(),
],
),
onTap: () async {
final selected = await Navigator.push(
context,
CupertinoPageRoute(
builder: (context) => ModeSelectionMenu(
pointLimit: Globals.pointLimit,
),
),
);
if (selected != null) {
setState(() {
selectedMode = selected;
});
}
},
),
),
Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 0, 0),
child: Text(
AppLocalizations.of(context).players,
style: CustomTheme.rowTitle,
),
),
Expanded(
child: ListView.builder(
itemCount: _playerNameTextControllers.length +
1, // +1 für den + Button
itemBuilder: (context, index) {
if (index == _playerNameTextControllers.length) {
// + Button als letztes Element
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: CupertinoButton(
padding: EdgeInsets.zero,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
CupertinoIcons.add_circled,
color: CupertinoColors.activeGreen,
size: 25,
),
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 {
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),
),
],
),
);
}
},
),
);
} else {
// Spieler-Einträge
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 8.0, horizontal: 5),
child: Row(
children: [
CupertinoButton(
padding: EdgeInsets.zero,
child: const Icon(
CupertinoIcons.minus_circle_fill,
color: CupertinoColors.destructiveRed,
size: 25,
),
onPressed: () {
setState(() {
_playerNameTextControllers[index].dispose();
_playerNameTextControllers.removeAt(index);
});
},
),
Expanded(
child: CupertinoTextField(
controller: _playerNameTextControllers[index],
placeholder:
'${AppLocalizations.of(context).player} ${index + 1}',
padding: const EdgeInsets.all(12),
decoration: const BoxDecoration(),
),
),
],
),
);
}
},
),
),
Center(
child: CupertinoButton(
padding: EdgeInsets.zero,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
AppLocalizations.of(context).create_game,
style: const TextStyle(
color: CupertinoColors.activeGreen,
),
),
],
),
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),
),
],
),
);
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),
),
],
),
);
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),
),
],
),
);
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),
),
],
),
);
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: Globals.pointLimit,
caboPenalty: Globals.caboPenalty,
isPointsLimitEnabled: selectedMode!,
);
final index = await gameManager.addGameSession(gameSession);
if (context.mounted) {
Navigator.pushReplacement(
context,
CupertinoPageRoute(
builder: (context) => ActiveGameView(
gameSession: gameManager.gameList[index])));
}
},
),
),
],
))));
}
bool everyPlayerHasAName() {
for (var controller in _playerNameTextControllers) {
if (controller.text == '') {
return false;
}
}
return true;
}
@override
void dispose() {
for (var controller in _playerNameTextControllers) {
controller.dispose();
}
super.dispose();
}
}

View File

@@ -1,84 +0,0 @@
import 'package:cabo_counter/data/models/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,72 +0,0 @@
import 'package:cabo_counter/l10n/app_localizations.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:url_launcher/url_launcher.dart';
class InformationView extends StatelessWidget {
const InformationView({super.key});
@override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
resizeToAvoidBottomInset: false,
navigationBar: CupertinoNavigationBar(
middle: Text(AppLocalizations.of(context).about),
),
child: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(0, 10, 0, 0),
child: Text(
AppLocalizations.of(context).app_name,
style: const TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
),
),
),
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 30),
child: SizedBox(
height: 200,
child: Image.asset('assets/cabo_counter-logo_rounded.png'),
)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 30),
child: Text(
AppLocalizations.of(context).about_text,
textAlign: TextAlign.center,
softWrap: true,
)),
const SizedBox(
height: 30,
),
const Text(
'\u00A9 Felix Kirchner',
style: TextStyle(fontSize: 16),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
onPressed: () =>
launchUrl(Uri.parse('https://www.instagram.com/fx.kr')),
icon: const Icon(FontAwesomeIcons.instagram)),
IconButton(
onPressed: () => launchUrl(
Uri.parse('mailto:felix.kirchner.fk@gmail.com')),
icon: const Icon(CupertinoIcons.envelope)),
IconButton(
onPressed: () =>
launchUrl(Uri.parse('https://www.github.com/flixcoo')),
icon: const Icon(FontAwesomeIcons.github)),
],
),
],
)));
}
}

View File

@@ -1,233 +0,0 @@
import 'package:cabo_counter/data/models/game_manager.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/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';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
class MainMenuView extends StatefulWidget {
const MainMenuView({super.key});
@override
// ignore: library_private_types_in_public_api
_MainMenuViewState createState() => _MainMenuViewState();
}
class _MainMenuViewState extends State<MainMenuView> {
bool _isLoading = true;
@override
initState() {
super.initState();
LocalStorageService.loadGameSessions().then((_) {
setState(() {
_isLoading = false;
});
});
gameManager.addListener(_updateView);
}
void _updateView() {
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: gameManager,
builder: (context, _) {
return CupertinoPageScaffold(
resizeToAvoidBottomInset: false,
navigationBar: CupertinoNavigationBar(
leading: IconButton(
onPressed: () {
Navigator.push(
context,
CupertinoPageRoute(
builder: (context) => const SettingsView(),
),
);
},
icon: const Icon(CupertinoIcons.settings, size: 30)),
middle: const Text('Cabo Counter'),
trailing: IconButton(
onPressed: () => {
Navigator.push(
context,
CupertinoPageRoute(
builder: (context) => const CreateGame(),
),
)
},
icon: const Icon(CupertinoIcons.add)),
),
child: CupertinoPageScaffold(
child: SafeArea(
child: _isLoading
? const Center(child: CupertinoActivityIndicator())
: gameManager.gameList.isEmpty
? Column(
mainAxisAlignment:
MainAxisAlignment.center, // Oben ausrichten
children: [
const SizedBox(height: 30), // Abstand von oben
Center(
child: GestureDetector(
onTap: () => setState(() {}),
child: Icon(
CupertinoIcons.plus,
size: 60,
color: CustomTheme.primaryColor,
),
)),
const SizedBox(height: 10), // Abstand von oben
const Padding(
padding: EdgeInsets.symmetric(horizontal: 70),
child: Text(
'Ganz schön leer hier...\nFüge über den Button oben rechts eine neue Runde hinzu.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16),
),
),
],
)
: ListView.builder(
itemCount: gameManager.gameList.length,
itemBuilder: (context, index) {
final session = gameManager.gameList[index];
return ListenableBuilder(
listenable: session,
builder: (context, _) {
return Dismissible(
key: Key(session.gameTitle),
background: Container(
color: CupertinoColors.destructiveRed,
alignment: Alignment.centerLeft,
padding:
const EdgeInsets.only(left: 20.0),
child: const Icon(
CupertinoIcons.delete,
color: CupertinoColors.white,
),
),
direction: DismissDirection.startToEnd,
confirmDismiss: (direction) async {
final String gameTitle = gameManager
.gameList[index].gameTitle;
return await _showDeleteGamePopup(
gameTitle);
},
onDismissed: (direction) {
gameManager.removeGameSession(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: () async {
//ignore: unused_local_variable
final val = await Navigator.push(
context,
CupertinoPageRoute(
builder: (context) =>
ActiveGameView(
gameSession: gameManager
.gameList[index]),
),
);
setState(() {});
},
),
),
);
});
},
),
),
),
);
});
}
/// Translates the game mode boolean into the corresponding String.
/// 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 AppLocalizations.of(context).unlimited;
}
/// Shows a confirmation dialog to delete all game sessions.
/// Returns true if the user confirms the deletion, false otherwise.
/// [gameTitle] is the title of the game session to be deleted.
Future<bool> _showDeleteGamePopup(String gameTitle) async {
bool? shouldDelete = await showCupertinoDialog<bool>(
context: context,
builder: (context) {
return CupertinoAlertDialog(
title: Text(AppLocalizations.of(context).delete_game_title),
content: Text(
AppLocalizations.of(context).delete_game_message(gameTitle)),
actions: [
CupertinoDialogAction(
onPressed: () {
Navigator.pop(context, false);
},
child: Text(AppLocalizations.of(context).cancel),
),
CupertinoDialogAction(
onPressed: () {
Navigator.pop(context, true);
},
child: Text(AppLocalizations.of(context).delete),
),
],
);
},
) ??
false;
return shouldDelete;
}
@override
void dispose() {
gameManager.removeListener(_updateView);
super.dispose();
}
}

View File

@@ -1,52 +0,0 @@
import 'package:cabo_counter/l10n/app_localizations.dart';
import 'package:cabo_counter/utility/custom_theme.dart';
import 'package:flutter/cupertino.dart';
class ModeSelectionMenu extends StatelessWidget {
final int pointLimit;
const ModeSelectionMenu({super.key, required this.pointLimit});
@override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(AppLocalizations.of(context).select_game_mode),
),
child: ListView(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(0, 16, 0, 0),
child: CupertinoListTile(
title: Text('$pointLimit ${AppLocalizations.of(context).points}',
style: CustomTheme.modeTitle),
subtitle: Text(
AppLocalizations.of(context)
.point_limit_description(pointLimit),
style: CustomTheme.modeDescription,
maxLines: 3,
),
onTap: () {
Navigator.pop(context, true);
},
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: CupertinoListTile(
title: Text(AppLocalizations.of(context).unlimited,
style: CustomTheme.modeTitle),
subtitle: Text(
AppLocalizations.of(context).unlimited_description,
style: CustomTheme.modeDescription,
maxLines: 3,
),
onTap: () {
Navigator.pop(context, false);
},
),
),
],
),
);
}
}

View File

@@ -1,408 +0,0 @@
import 'package:cabo_counter/data/models/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:flutter/cupertino.dart';
import 'package:flutter/services.dart';
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
class RoundView extends StatefulWidget {
final GameSession gameSession;
final int roundNumber;
const RoundView(
{super.key, required this.roundNumber, required this.gameSession});
@override
// ignore: library_private_types_in_public_api
_RoundViewState createState() => _RoundViewState();
}
class _RoundViewState extends State<RoundView> {
/// The current game session.
late GameSession gameSession = widget.gameSession;
/// Index of the player who said CABO.
int _caboPlayerIndex = 0;
/// Index of the player who has Kamikaze.
/// Default is null (no Kamikaze player).
int? _kamikazePlayerIndex;
/// List of text controllers for the score text fields.
late final List<TextEditingController> _scoreControllerList = List.generate(
widget.gameSession.players.length,
(index) => TextEditingController(),
);
/// List of focus nodes for the score text fields.
late final List<FocusNode> _focusNodeList = List.generate(
widget.gameSession.players.length,
(index) => FocusNode(),
);
@override
void initState() {
print('=== Runde ${widget.roundNumber} geöffnet ===');
if (widget.roundNumber < widget.gameSession.roundNumber ||
widget.gameSession.isGameFinished == true) {
print(
'Diese wurde bereits gespielt, deshalb werden die alten Punktestaende angezeigt');
// If the current round has already been played, the text fields
// are filled with the scores from this round
for (int i = 0; i < _scoreControllerList.length; i++) {
_scoreControllerList[i].text =
gameSession.roundList[widget.roundNumber - 1].scores[i].toString();
}
_caboPlayerIndex =
gameSession.roundList[widget.roundNumber - 1].caboPlayerIndex;
_kamikazePlayerIndex =
gameSession.roundList[widget.roundNumber - 1].kamikazePlayerIndex;
}
super.initState();
}
@override
@override
Widget build(BuildContext context) {
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
return CupertinoPageScaffold(
resizeToAvoidBottomInset: false,
navigationBar: CupertinoNavigationBar(
transitionBetweenRoutes: true,
middle: Text(AppLocalizations.of(context).results),
leading: CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () => {
LocalStorageService.saveGameSessions(),
Navigator.pop(context, widget.gameSession)
},
child: Text(AppLocalizations.of(context).cancel),
),
),
child: Stack(
children: [
Positioned.fill(
child: SingleChildScrollView(
padding: EdgeInsets.only(bottom: 100 + bottomInset),
child: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 40),
Text(
'${AppLocalizations.of(context).round} ${widget.roundNumber}',
style: CustomTheme.roundTitle),
const SizedBox(height: 10),
Text(
AppLocalizations.of(context).who_said_cabo,
style: const TextStyle(fontWeight: FontWeight.bold),
),
Padding(
padding: EdgeInsets.symmetric(
horizontal:
widget.gameSession.players.length > 3 ? 5 : 20,
vertical: 10,
),
child: SizedBox(
height: 40,
child: CupertinoSegmentedControl<int>(
unselectedColor: CustomTheme.backgroundTintColor,
selectedColor: CustomTheme.primaryColor,
groupValue: _caboPlayerIndex,
children: Map.fromEntries(widget.gameSession.players
.asMap()
.entries
.map((entry) {
final index = entry.key;
final name = entry.value;
return MapEntry(
index,
Padding(
padding: EdgeInsets.symmetric(
horizontal: widget.gameSession
.getLengthOfPlayerNames() >
20
? (widget.gameSession
.getLengthOfPlayerNames() >
32
? 5
: 10)
: 15,
vertical: 6,
),
child: Text(
name,
textAlign: TextAlign.center,
maxLines: 1,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: widget.gameSession
.getLengthOfPlayerNames() >
28
? 14
: 18,
),
),
),
);
})),
onValueChanged: (value) {
setState(() {
_caboPlayerIndex = value;
});
},
),
),
),
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(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: widget.gameSession.players.length,
itemBuilder: (context, index) {
final name = widget.gameSession.players[index];
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 10, horizontal: 20),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CupertinoListTile(
backgroundColor: CupertinoColors.secondaryLabel,
title: Row(children: [Text(name)]),
subtitle: Text(
'${widget.gameSession.playerScores[index]}'
' ${AppLocalizations.of(context).points}'),
trailing: Row(
children: [
SizedBox(
width: 100,
child: CupertinoTextField(
maxLength: 3,
focusNode: _focusNodeList[index],
keyboardType:
const TextInputType.numberWithOptions(
signed: true,
decimal: false,
),
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
textInputAction: index ==
widget.gameSession.players
.length -
1
? TextInputAction.done
: TextInputAction.next,
controller: _scoreControllerList[index],
placeholder:
AppLocalizations.of(context).points,
textAlign: TextAlign.center,
onSubmitted: (_) =>
_focusNextTextfield(index),
onChanged: (_) => setState(() {}),
),
),
const SizedBox(width: 50),
GestureDetector(
onTap: () {
setState(() {
_kamikazePlayerIndex =
(_kamikazePlayerIndex == index)
? null
: index;
});
},
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _kamikazePlayerIndex == index
? CupertinoColors.systemRed
: CupertinoColors
.tertiarySystemFill,
border: Border.all(
color: _kamikazePlayerIndex == index
? CupertinoColors.systemRed
: CupertinoColors.systemGrey,
),
),
child: _kamikazePlayerIndex == index
? const Icon(
CupertinoIcons.exclamationmark,
size: 16,
color: CupertinoColors.white,
)
: null,
),
),
const SizedBox(width: 22),
],
),
),
),
);
},
),
],
),
),
),
),
Positioned(
left: 0,
right: 0,
bottom: bottomInset,
child: KeyboardVisibilityBuilder(builder: (context, visible) {
if (!visible) {
return Container(
height: 80,
padding: const EdgeInsets.only(bottom: 20),
color: CustomTheme.backgroundTintColor,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
CupertinoButton(
onPressed: _areRoundInputsValid()
? () {
_finishRound();
LocalStorageService.saveGameSessions();
Navigator.pop(context, widget.gameSession);
}
: null,
child: Text(AppLocalizations.of(context).done),
),
CupertinoButton(
onPressed: _areRoundInputsValid()
? () {
_finishRound();
LocalStorageService.saveGameSessions();
if (widget.gameSession.isGameFinished == true) {
Navigator.pop(context, widget.gameSession);
} else {
Navigator.of(context, rootNavigator: true)
.pushReplacement(
CupertinoPageRoute(
builder: (context) => RoundView(
gameSession: widget.gameSession,
roundNumber: widget.roundNumber + 1,
),
),
);
}
}
: null,
child: Text(AppLocalizations.of(context).next_round),
),
],
),
);
} else {
return const SizedBox.shrink();
}
}),
)
],
),
);
}
/// Focuses the next text field in the list of text fields.
/// [index] is the index of the current text field.
void _focusNextTextfield(int index) {
if (index < widget.gameSession.players.length - 1) {
FocusScope.of(context).requestFocus(_focusNodeList[index + 1]);
} else {
_focusNodeList[index].unfocus();
}
}
/// Checks if the inputs for the round are valid.
/// Returns true if the inputs are valid, false otherwise.
/// Round Inputs are valid if every player has a score or
/// kamikaze is selected for a player
bool _areRoundInputsValid() {
if (_areTextFieldsEmpty() && _kamikazePlayerIndex == null) return false;
return true;
}
/// Checks if any of the text fields for the players points are empty.
/// Returns true if any of the text fields is empty, false otherwise.
bool _areTextFieldsEmpty() {
for (TextEditingController t in _scoreControllerList) {
if (t.text.isEmpty) {
return true;
}
}
return false;
}
/// Finishes the current round.
/// It first determines, ifCalls the [_calculateScoredPoints()] method to calculate the points for
/// every player. If the round is the highest round played in this game,
/// it expands the player score lists. At the end it updates the score
/// array for the game.
void _finishRound() {
print('====================================');
print('Runde ${widget.roundNumber} beendet');
// The shown round is smaller than the newest round
if (widget.roundNumber < widget.gameSession.roundNumber) {
print('Da diese Runde bereits gespielt wurde, werden die alten '
'Punktestaende ueberschrieben');
}
if (_kamikazePlayerIndex != null) {
print('${widget.gameSession.players[_kamikazePlayerIndex!]} hat Kamikaze '
'und bekommt 0 Punkte');
print('Alle anderen Spieler bekommen 50 Punkte');
widget.gameSession
.applyKamikaze(widget.roundNumber, _kamikazePlayerIndex!);
} else {
List<int> roundScores = [];
for (TextEditingController c in _scoreControllerList) {
if (c.text.isNotEmpty) roundScores.add(int.parse(c.text));
}
widget.gameSession.calculateScoredPoints(
widget.roundNumber, roundScores, _caboPlayerIndex);
}
widget.gameSession.updatePoints();
if (widget.gameSession.isGameFinished == true) {
print('Das Spiel ist beendet');
} else if (widget.roundNumber == widget.gameSession.roundNumber) {
widget.gameSession.increaseRound();
}
}
@override
void dispose() {
for (final controller in _scoreControllerList) {
controller.dispose();
}
for (final focusNode in _focusNodeList) {
focusNode.dispose();
}
super.dispose();
}
}

View File

@@ -1,239 +0,0 @@
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/widgets/stepper.dart';
import 'package:flutter/cupertino.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher.dart';
class SettingsView extends StatefulWidget {
const SettingsView({super.key});
@override
State<SettingsView> createState() => _SettingsViewState();
}
class _SettingsViewState extends State<SettingsView> {
UniqueKey _stepperKey1 = UniqueKey();
UniqueKey _stepperKey2 = UniqueKey();
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(AppLocalizations.of(context).settings),
),
child: SafeArea(
child: Stack(
children: [
Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 0, 0),
child: Text(
AppLocalizations.of(context).points,
style: CustomTheme.rowTitle,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(15, 10, 10, 0),
child: CupertinoListTile(
padding: EdgeInsets.zero,
title: Text(AppLocalizations.of(context).cabo_penalty),
subtitle: Text(
AppLocalizations.of(context).cabo_penalty_subtitle),
trailing: Stepper(
key: _stepperKey1,
initialValue: Globals.caboPenalty,
minValue: 0,
maxValue: 50,
step: 1,
onChanged: (newCaboPenalty) {
setState(() {
ConfigService.setCaboPenalty(newCaboPenalty);
Globals.caboPenalty = newCaboPenalty;
});
},
),
)),
Padding(
padding: const EdgeInsets.fromLTRB(15, 10, 10, 0),
child: CupertinoListTile(
padding: EdgeInsets.zero,
title: Text(AppLocalizations.of(context).point_limit),
subtitle:
Text(AppLocalizations.of(context).point_limit_subtitle),
trailing: Stepper(
key: _stepperKey2,
initialValue: Globals.pointLimit,
minValue: 30,
maxValue: 1000,
step: 10,
onChanged: (newPointLimit) {
setState(() {
ConfigService.setPointLimit(newPointLimit);
Globals.pointLimit = newPointLimit;
});
},
),
)),
Padding(
padding: const EdgeInsets.fromLTRB(0, 10, 0, 0),
child: Center(
heightFactor: 0.9,
child: CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () => setState(() {
ConfigService.resetConfig();
_stepperKey1 = UniqueKey();
_stepperKey2 = UniqueKey();
}),
child:
Text(AppLocalizations.of(context).reset_to_default),
),
)),
Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 0, 0),
child: Text(
AppLocalizations.of(context).game_data,
style: CustomTheme.rowTitle,
),
),
Padding(
padding: const EdgeInsets.only(top: 30),
child: Center(
heightFactor: 1,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CupertinoButton(
color: CustomTheme.primaryColor,
sizeStyle: CupertinoButtonSize.medium,
child: Text(
AppLocalizations.of(context).import_data,
style:
TextStyle(color: CustomTheme.backgroundColor),
),
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),
),
],
));
}
}),
const SizedBox(
width: 20,
),
CupertinoButton(
color: CustomTheme.primaryColor,
sizeStyle: CupertinoButtonSize.medium,
child: Text(
AppLocalizations.of(context).export_data,
style:
TextStyle(color: CustomTheme.backgroundColor),
),
onPressed: () async {
final success =
await LocalStorageService.exportJsonFile();
if (!success && context.mounted) {
showCupertinoDialog(
context: context,
builder: (context) => CupertinoAlertDialog(
title:
Text(AppLocalizations.of(context).error),
content: Text(AppLocalizations.of(context)
.error_export),
actions: [
CupertinoDialogAction(
child:
Text(AppLocalizations.of(context).ok),
onPressed: () => Navigator.pop(context),
),
],
),
);
}
},
),
],
)),
)
],
),
Positioned(
bottom: 30,
left: 0,
right: 0,
child: Column(
children: [
Center(
child: Text(AppLocalizations.of(context).error_found),
),
Padding(
padding: const EdgeInsets.fromLTRB(0, 0, 0, 30),
child: Center(
child: CupertinoButton(
onPressed: () => launchUrl(Uri.parse(
'https://github.com/flixcoo/Cabo-Counter/issues')),
child: Text(AppLocalizations.of(context).create_issue),
),
),
),
FutureBuilder<PackageInfo>(
future: _getPackageInfo(),
builder: (context, snapshot) {
if (snapshot.hasData) {
return Text(
'${Globals.appDevPhase} ${snapshot.data!.version} '
'(${AppLocalizations.of(context).build} ${snapshot.data!.buildNumber})',
textAlign: TextAlign.center,
);
} else if (snapshot.hasError) {
return Text(
'${AppLocalizations.of(context).app_version} -.-.- (${AppLocalizations.of(context).build} -)',
textAlign: TextAlign.center,
);
}
return Text(
AppLocalizations.of(context).load_version,
textAlign: TextAlign.center,
);
},
)
],
)),
],
)),
);
}
Future<PackageInfo> _getPackageInfo() async {
return await PackageInfo.fromPlatform();
}
}

View File

@@ -2,7 +2,7 @@ name: cabo_counter
description: "Mobile app for the card game Cabo"
publish_to: 'none'
version: 0.3.2+245
version: 0.5.8+674
environment:
sdk: ^3.5.4
@@ -26,6 +26,13 @@ dependencies:
sdk: flutter
intl: any
syncfusion_flutter_charts: ^30.1.37
uuid: ^4.5.1
rate_my_app: ^2.3.2
reorderables: ^0.4.2
collection: ^1.18.0
confetti: ^0.6.0
flutter_oss_licenses: ^2.0.1
google_fonts: ^6.3.0
drift: ^2.26.1
drift_flutter: ^0.2.4
@@ -42,4 +49,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

@@ -9,6 +9,7 @@ void main() {
setUp(() {
session = GameSession(
id: '1',
createdAt: testDate,
gameTitle: testTitle,
players: testPlayers,
@@ -61,11 +62,6 @@ void main() {
});
group('Helper Functions', () {
test('getLengthOfPlayerNames', () {
expect(session.getLengthOfPlayerNames(),
equals(15)); // Alice(5) + Bob(3) + Charlie(7)
});
test('increaseRound', () {
expect(session.roundNumber, 1);
session.increaseRound();
@@ -115,15 +111,15 @@ void main() {
expect(session.roundList[0].caboPlayerIndex, 0);
});
test('updatePoints - game not finished', () async {
test('updatePoints - game not finished', () {
session.addRoundScoresToList(1, [10, 20, 30], [10, 20, 30], 0);
await session.updatePoints();
session.updatePoints();
expect(session.isGameFinished, isFalse);
});
test('updatePoints - game finished', () async {
test('updatePoints - game finished', () {
session.addRoundScoresToList(1, [101, 20, 30], [101, 20, 30], 0);
await session.updatePoints();
session.updatePoints();
expect(session.isGameFinished, isTrue);
});
@@ -155,9 +151,9 @@ void main() {
expect(session.playerScores, equals([50, 0, 30]));
});
test('_setWinner via updatePoints', () async {
test('_setWinner via updatePoints', () {
session.addRoundScoresToList(1, [101, 20, 30], [101, 0, 30], 1);
await session.updatePoints();
session.updatePoints();
expect(session.winner, 'Bob'); // Bob has lowest score (20)
});
});