Beta-Version 0.4.4 (#105)

* Update README.md

* Tried new design for im- and export-button

* Moved views to presentation folder

* Moved widgets to presentation folder

* Implemented CustomRowForm Widget

* Used new custom form row

* Removed double information

* Refactored methods to private

* Changed label

* Modified paddings and text color

* Changed string

* Updated CustomFormRow padding and pressed handler

* Implemented various new forms of CustomFormRow into SettingsView

* Implemented VersionService

* Updated strings, added wiki button

* Corrected replaced string

* Added import dialog feedback (got lost in refactoring)

* Corrected function duplication

* changed suffixWidget assignment and moved stepperKeys

* Changed icons

* Added rate_my_app package

* Renamed folder

* Implement native rating dialog

* Implemented logic for pre rating and refactored rating dialog

* updated launch mode

* Small changes

* Updated launch mode

* Updated linting rules

* Renamed folders

* Changed l10n files location

* Implemented new link constants

* Changed privacy policy link

* Corrected wiki link

* Removed import

* Updated links

* Updated links to subdomains

* Updated file paths

* Updated strings

* Updated identifiers

* Added break in switch case

* Updated strings

* Implemented new popup

* Corrected links

* Changed color

* Ensured rating dialog wont show in Beta

* Refactoring

* Adding const

* Renamed variables

* Corrected links

* updated Dialog function

* Added version number in about view

* Changed order and corrected return

* Changed translation

* Changed popups because of unmounted context errors

* corrected string typo

* Replaced int constants with enums

* Renamed Stepper to CustomStepper

* Changed argument order

* Reordered properties

* Implemented empty builder for GraphView

* Added jitterStip to prevent the graphs overlaying each other

* Removed german comments

* Added comment to jitter calculation

* Overhauled comments in CustomTheme

* Updated version

* Added Delete all games button to Settings

* Updated version

* Updated en string

* Updated RoundView buttons when game is finished

* Changed lock emoji to CuperinoIcons.lock and placed it in trailing of app bar

* Simplified comparison

* Updated version

* Corrected scaling

* Updates constant names and lint rule

* HOTFIX: Graph showed wrong data

* Graph starts at round 0 now where all players have 0 points

* Adjusted jitterStep

* Removed dead code

* Updated Y-Axis and removed values under y = 0

* Changed overflow mode

* Replaced string & if statement with visibility widget

* updated accessability of graph view

* Changed string for GraphView title

* Updated comment

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Updated generated files

* Updated version in README

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
2025-07-13 12:48:24 +02:00
committed by GitHub
parent a3003047ae
commit 8565382fab
29 changed files with 1024 additions and 545 deletions

View File

@@ -1,6 +1,6 @@
# CABO Counter
![Version](https://img.shields.io/badge/Version-0.3.8-orange)
![Version](https://img.shields.io/badge/Version-0.4.4-orange)
![Flutter](https://img.shields.io/badge/Flutter-3.32.1-blue?logo=flutter)
![Dart](https://img.shields.io/badge/Dart-3.8.1-blue?logo=dart)
![iOS](https://img.shields.io/badge/iOS-18.5-white?logo=apple)

View File

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

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-dir: lib/l10n/generated

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

@@ -0,0 +1,22 @@
import 'package:rate_my_app/rate_my_app.dart';
class Constants {
static const String appDevPhase = 'Beta';
static const String kInstagramLink = 'https://instagram.felixkirchner.de';
static const String kGithubLink = 'https://github.felixkirchner.de';
static const String kGithubIssuesLink =
'https://cabocounter-issues.felixkirchner.de';
static const String kGithubWikiLink =
'https://cabocounter-wiki.felixkirchner.de';
static const String kEmail = 'cabocounter@felixkirchner.de';
static const String kPrivacyPolicyLink =
'https://www.privacypolicies.com/live/1b3759d4-b2f1-4511-8e3b-21bb1626be68';
static RateMyApp rateMyApp = RateMyApp(
appStoreIdentifier: '6747105718',
minDays: 15,
remindDays: 45,
minLaunches: 15,
remindLaunches: 40);
}

View File

@@ -6,6 +6,13 @@ class CustomTheme {
static Color backgroundColor = const Color(0xFF101010);
static Color backgroundTintColor = CupertinoColors.darkBackgroundGray;
// 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;
static TextStyle modeTitle = TextStyle(
color: primaryColor,
fontSize: 20,

View File

@@ -30,6 +30,16 @@
}
}
},
"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",
@@ -64,7 +74,6 @@
"done": "Fertig",
"next_round": "Nächste Runde",
"statistics": "Statistiken",
"end_game": "Spiel beenden",
"delete_game": "Spiel löschen",
"new_game_same_settings": "Neues Spiel mit gleichen Einstellungen",
@@ -75,6 +84,7 @@
"end_game_message": "Möchtest du das Spiel beenden? Das Spiel wird als beendet markiert und kann nicht fortgeführt werden.",
"game_process": "Spielverlauf",
"empty_graph_text": "Du musst mindestens eine Runde spielen, damit der Graph des Spielverlaufes angezeigt werden kann.",
"settings": "Einstellungen",
"cabo_penalty": "Cabo-Strafe",
@@ -83,8 +93,12 @@
"point_limit_subtitle": "... hier ist Schluss",
"reset_to_default": "Auf Standard zurücksetzen",
"game_data": "Spieldaten",
"import_data": "Daten importieren",
"export_data": "Daten exportieren",
"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.",
@@ -92,16 +106,19 @@
"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_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",
"build": "Build",
"load_version": "Lade Version...",
"privacy_policy": "Datenschutzerklärung",
"build": "Build-Nr.",
"loading": "Lädt...",
"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

@@ -30,6 +30,16 @@
}
}
},
"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": "If you are not satisfied with the app, please let me know before leaving a bad rating. I will try to fix the issue as soon as possible.",
"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",
@@ -64,13 +74,17 @@
"done": "Done",
"next_round": "Next Round",
"statistics": "Statistics",
"end_game": "End Game",
"delete_game": "Delete Game",
"new_game_same_settings": "New Game with same Settings",
"export_game": "Export Game",
"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.",
"game_process": "Spielverlauf",
"game_process": "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",
@@ -81,10 +95,10 @@
"game_data": "Game Data",
"import_data": "Import Data",
"export_data": "Export Data",
"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.",
"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.",
@@ -97,11 +111,14 @@
"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",
"load_version": "Loading version...",
"build": "Build",
"privacy_policy": "Privacy Policy",
"loading": "Loading...",
"build": "Build No.",
"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

@@ -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,
@@ -218,6 +218,60 @@ abstract class AppLocalizations {
/// **'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:
@@ -362,12 +416,6 @@ abstract class AppLocalizations {
/// **'Nächste Runde'**
String get next_round;
/// No description provided for @statistics.
///
/// In de, this message translates to:
/// **'Statistiken'**
String get statistics;
/// No description provided for @end_game.
///
/// In de, this message translates to:
@@ -422,6 +470,12 @@ abstract class AppLocalizations {
/// **'Spielverlauf'**
String get game_process;
/// 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.
///
/// In de, this message translates to:
@@ -467,15 +521,39 @@ 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 @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:
@@ -548,23 +626,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 @build.
///
/// In de, this message translates to:
/// **'Build'**
/// **'Build-Nr.'**
String get build;
/// No description provided for @load_version.
/// No description provided for @loading.
///
/// In de, this message translates to:
/// **'Lade Version...'**
String get load_version;
/// **'Lädt...'**
String get loading;
/// No description provided for @about_text.
///

View File

@@ -71,6 +71,35 @@ class AppLocalizationsDe extends AppLocalizations {
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';
@@ -149,9 +178,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get next_round => 'Nächste Runde';
@override
String get statistics => 'Statistiken';
@override
String get end_game => 'Spiel beenden';
@@ -181,6 +207,10 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get game_process => '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';
@@ -203,10 +233,23 @@ class AppLocalizationsDe extends AppLocalizations {
String get game_data => 'Spieldaten';
@override
String get import_data => 'Daten importieren';
String get import_data => 'Spieldaten importieren';
@override
String get export_data => 'Daten exportieren';
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';
@@ -247,14 +290,20 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get create_issue => 'Issue erstellen';
@override
String get wiki => 'Wiki';
@override
String get app_version => 'App-Version';
@override
String get build => 'Build';
String get privacy_policy => 'Datenschutzerklärung';
@override
String get load_version => 'Lade Version...';
String get build => 'Build-Nr.';
@override
String get loading => 'Lädt...';
@override
String get about_text =>

View File

@@ -71,6 +71,35 @@ class AppLocalizationsEn extends AppLocalizations {
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 =>
'If you are not satisfied with the app, please let me know before leaving a bad rating. I will try to fix the issue as soon as possible.';
@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';
@@ -146,9 +175,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get next_round => 'Next Round';
@override
String get statistics => 'Statistics';
@override
String get end_game => 'End Game';
@@ -176,7 +202,11 @@ class AppLocalizationsEn extends AppLocalizations {
'Do you want to end the game? The game gets marked as finished and cannot be continued.';
@override
String get game_process => 'Spielverlauf';
String get game_process => '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';
@@ -205,6 +235,19 @@ class AppLocalizationsEn extends AppLocalizations {
@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';
@@ -244,14 +287,20 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get create_issue => 'Create Issue';
@override
String get wiki => 'Wiki';
@override
String get app_version => 'App Version';
@override
String get build => 'Build';
String get privacy_policy => 'Privacy Policy';
@override
String get load_version => 'Loading version...';
String get build => 'Build No.';
@override
String get loading => 'Loading...';
@override
String get about_text =>

View File

@@ -1,8 +1,9 @@
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/views/tab_view.dart';
import 'package:cabo_counter/services/version_service.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';
@@ -13,6 +14,7 @@ Future<void> main() async {
await ConfigService.initConfig();
ConfigService.pointLimit = await ConfigService.getPointLimit();
ConfigService.caboPenalty = await ConfigService.getCaboPenalty();
await VersionService.init();
runApp(const App());
}

View File

@@ -1,11 +1,13 @@
import 'package:cabo_counter/l10n/app_localizations.dart';
import 'package:cabo_counter/core/constants.dart';
import 'package:cabo_counter/l10n/generated/app_localizations.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';
class InformationView extends StatelessWidget {
const InformationView({super.key});
class AboutView extends StatelessWidget {
const AboutView({super.key});
@override
Widget build(BuildContext context) {
@@ -28,9 +30,13 @@ class InformationView extends StatelessWidget {
),
),
),
Text(
'${AppLocalizations.of(context).app_version} ${VersionService.getVersionWithBuild()}',
style: TextStyle(fontSize: 15, color: Colors.grey[300]),
),
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 30),
const EdgeInsets.symmetric(horizontal: 20, vertical: 15),
child: SizedBox(
height: 200,
child: Image.asset('assets/cabo_counter-logo_rounded.png'),
@@ -54,15 +60,15 @@ class InformationView extends StatelessWidget {
children: [
IconButton(
onPressed: () =>
launchUrl(Uri.parse('https://www.instagram.com/fx.kr')),
launchUrl(Uri.parse(Constants.kInstagramLink)),
icon: const Icon(FontAwesomeIcons.instagram)),
IconButton(
onPressed: () => launchUrl(
Uri.parse('mailto:felix.kirchner.fk@gmail.com')),
onPressed: () =>
launchUrl(Uri.parse('mailto:${Constants.kEmail}')),
icon: const Icon(CupertinoIcons.envelope)),
IconButton(
onPressed: () =>
launchUrl(Uri.parse('https://www.github.com/flixcoo')),
launchUrl(Uri.parse(Constants.kGithubLink)),
icon: const Icon(FontAwesomeIcons.github)),
],
),

View File

@@ -1,11 +1,11 @@
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/app_localizations.dart';
import 'package:cabo_counter/l10n/generated/app_localizations.dart';
import 'package:cabo_counter/presentation/views/create_game_view.dart';
import 'package:cabo_counter/presentation/views/graph_view.dart';
import 'package:cabo_counter/presentation/views/round_view.dart';
import 'package:cabo_counter/services/local_storage_service.dart';
import 'package:cabo_counter/utility/custom_theme.dart';
import 'package:cabo_counter/views/create_game_view.dart';
import 'package:cabo_counter/views/graph_view.dart';
import 'package:cabo_counter/views/round_view.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
@@ -121,7 +121,7 @@ class _ActiveGameViewState extends State<ActiveGameView> {
children: [
CupertinoListTile(
title: Text(
AppLocalizations.of(context).statistics,
AppLocalizations.of(context).game_process,
),
backgroundColorActivated:
CustomTheme.backgroundColor,
@@ -131,8 +131,9 @@ class _ActiveGameViewState extends State<ActiveGameView> {
builder: (_) => GraphView(
gameSession: gameSession,
)))),
if (!gameSession.isPointsLimitEnabled)
CupertinoListTile(
Visibility(
visible: !gameSession.isPointsLimitEnabled,
child: CupertinoListTile(
title: Text(
AppLocalizations.of(context).end_game,
style: gameSession.roundNumber > 1 &&
@@ -148,6 +149,7 @@ class _ActiveGameViewState extends State<ActiveGameView> {
_showEndGameDialog();
}
}),
),
CupertinoListTile(
title: Text(
AppLocalizations.of(context).delete_game,
@@ -235,7 +237,8 @@ class _ActiveGameViewState extends State<ActiveGameView> {
child: Text(
AppLocalizations.of(context).end_game,
style: const TextStyle(
fontWeight: FontWeight.bold, color: Colors.red),
fontWeight: FontWeight.bold,
color: CupertinoColors.destructiveRed),
),
onPressed: () {
setState(() {

View File

@@ -1,10 +1,10 @@
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/app_localizations.dart';
import 'package:cabo_counter/l10n/generated/app_localizations.dart';
import 'package:cabo_counter/presentation/views/active_game_view.dart';
import 'package:cabo_counter/presentation/views/mode_selection_view.dart';
import 'package:cabo_counter/services/config_service.dart';
import 'package:cabo_counter/utility/custom_theme.dart';
import 'package:cabo_counter/views/active_game_view.dart';
import 'package:cabo_counter/views/mode_selection_view.dart';
import 'package:flutter/cupertino.dart';
enum CreateStatus {

View File

@@ -0,0 +1,120 @@
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';
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).game_process),
previousPageTitle: AppLocalizations.of(context).back,
),
child: widget.gameSession.roundNumber > 1
? Padding(
padding: const EdgeInsets.fromLTRB(0, 100, 0, 0),
child: SfCartesianChart(
legend: const Legend(
overflowMode: LegendItemOverflowMode.wrap,
isVisible: true,
position: LegendPosition.bottom),
primaryXAxis: const NumericAxis(
interval: 1,
decimalPlaces: 0,
),
primaryYAxis: const NumericAxis(
interval: 1,
decimalPlaces: 0,
),
series: getCumulativeScores(),
),
)
: 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),
),
),
],
));
}
/// 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

@@ -1,13 +1,19 @@
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/l10n/app_localizations.dart';
import 'package:cabo_counter/l10n/generated/app_localizations.dart';
import 'package:cabo_counter/presentation/views/active_game_view.dart';
import 'package:cabo_counter/presentation/views/create_game_view.dart';
import 'package:cabo_counter/presentation/views/settings_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/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';
import 'package:url_launcher/url_launcher.dart';
enum PreRatingDialogDecision { yes, no, cancel }
enum BadRatingDialogDecision { email, cancel }
class MainMenuView extends StatefulWidget {
const MainMenuView({super.key});
@@ -29,6 +35,17 @@ class _MainMenuViewState extends State<MainMenuView> {
});
});
gameManager.addListener(_updateView);
WidgetsBinding.instance.addPostFrameCallback((_) async {
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() {
@@ -57,14 +74,12 @@ class _MainMenuViewState extends State<MainMenuView> {
icon: const Icon(CupertinoIcons.settings, size: 30)),
middle: const Text('Cabo Counter'),
trailing: IconButton(
onPressed: () => {
Navigator.push(
context,
CupertinoPageRoute(
builder: (context) => const CreateGameView(),
),
)
},
onPressed: () => Navigator.push(
context,
CupertinoPageRoute(
builder: (context) => const CreateGameView(),
),
),
icon: const Icon(CupertinoIcons.add)),
),
child: CupertinoPageScaffold(
@@ -73,10 +88,9 @@ class _MainMenuViewState extends State<MainMenuView> {
? const Center(child: CupertinoActivityIndicator())
: gameManager.gameList.isEmpty
? Column(
mainAxisAlignment:
MainAxisAlignment.center, // Oben ausrichten
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 30), // Abstand von oben
const SizedBox(height: 30),
Center(
child: GestureDetector(
onTap: () => Navigator.push(
@@ -92,7 +106,7 @@ class _MainMenuViewState extends State<MainMenuView> {
color: CustomTheme.primaryColor,
),
)),
const SizedBox(height: 10), // Abstand von oben
const SizedBox(height: 10),
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 70),
@@ -128,7 +142,7 @@ class _MainMenuViewState extends State<MainMenuView> {
final String gameTitle = gameManager
.gameList[index].gameTitle;
return await _showDeleteGamePopup(
gameTitle);
context, gameTitle);
},
onDismissed: (direction) {
gameManager
@@ -204,40 +218,144 @@ class _MainMenuViewState extends State<MainMenuView> {
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: 300));
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(String gameTitle) async {
bool? shouldDelete = await showCupertinoDialog<bool>(
Future<bool> _showDeleteGamePopup(
BuildContext context, String gameTitle) async {
return await showCupertinoDialog<bool>(
context: context,
builder: (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.pop(context, false);
},
child: Text(AppLocalizations.of(context).cancel),
title: Text(
AppLocalizations.of(context).delete_game_title,
),
CupertinoDialogAction(
onPressed: () {
Navigator.pop(context, true);
},
child: Text(
AppLocalizations.of(context).delete,
style: const TextStyle(
fontWeight: FontWeight.bold, color: Colors.red),
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;
return shouldDelete;
}
/// 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

View File

@@ -1,5 +1,5 @@
import 'package:cabo_counter/l10n/app_localizations.dart';
import 'package:cabo_counter/utility/custom_theme.dart';
import 'package:cabo_counter/core/custom_theme.dart';
import 'package:cabo_counter/l10n/generated/app_localizations.dart';
import 'package:flutter/cupertino.dart';
class ModeSelectionMenu extends StatelessWidget {

View File

@@ -1,7 +1,7 @@
import 'package:cabo_counter/core/custom_theme.dart';
import 'package:cabo_counter/data/game_session.dart';
import 'package:cabo_counter/l10n/app_localizations.dart';
import 'package:cabo_counter/l10n/generated/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';
@@ -67,19 +67,24 @@ class _RoundViewState extends State<RoundView> {
@override
Widget build(BuildContext context) {
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
final maxLength = widget.gameSession.getMaxLengthOfPlayerNames();
return CupertinoPageScaffold(
resizeToAvoidBottomInset: false,
navigationBar: CupertinoNavigationBar(
transitionBetweenRoutes: true,
middle: Text(AppLocalizations.of(context).results),
leading: CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () =>
{LocalStorageService.saveGameSessions(), Navigator.pop(context)},
child: Text(AppLocalizations.of(context).cancel),
),
middle: Text(AppLocalizations.of(context).results),
trailing: widget.gameSession.isGameFinished
? const Icon(
CupertinoIcons.lock,
size: 25,
)
: null,
),
child: Stack(
children: [
@@ -120,9 +125,8 @@ class _RoundViewState extends State<RoundView> {
return MapEntry(
index,
Padding(
padding: EdgeInsets.symmetric(
horizontal: 4 +
_getSegmentedControlPadding(maxLength),
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 6,
),
child: FittedBox(
@@ -131,10 +135,8 @@ class _RoundViewState extends State<RoundView> {
name,
textAlign: TextAlign.center,
maxLines: 1,
style: TextStyle(
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: _getSegmentedControlFontSize(
maxLength),
),
),
),
@@ -293,21 +295,22 @@ class _RoundViewState extends State<RoundView> {
: null,
child: Text(AppLocalizations.of(context).done),
),
CupertinoButton(
onPressed: _areRoundInputsValid()
? () {
_finishRound();
LocalStorageService.saveGameSessions();
if (widget.gameSession.isGameFinished == true) {
Navigator.pop(context);
} else {
Navigator.pop(
context, widget.roundNumber + 1);
if (!widget.gameSession.isGameFinished)
CupertinoButton(
onPressed: _areRoundInputsValid()
? () {
_finishRound();
LocalStorageService.saveGameSessions();
if (widget.gameSession.isGameFinished) {
Navigator.pop(context);
} else {
Navigator.pop(
context, widget.roundNumber + 1);
}
}
}
: null,
child: Text(AppLocalizations.of(context).next_round),
),
: null,
child: Text(AppLocalizations.of(context).next_round),
),
],
),
);
@@ -386,32 +389,6 @@ class _RoundViewState extends State<RoundView> {
}
}
double _getSegmentedControlFontSize(int maxLength) {
if (maxLength > 8) {
// 9 - 12 characters
return 9.0;
} else if (maxLength > 4) {
// 5 - 8 characters
return 15.0;
} else {
// 0 - 4 characters
return 18.0;
}
}
double _getSegmentedControlPadding(int maxLength) {
if (maxLength > 8) {
// 9 - 12 characters
return 0.0;
} else if (maxLength > 4) {
// 5 - 8 characters
return 5.0;
} else {
// 0 - 4 characters
return 8.0;
}
}
@override
void dispose() {
for (final controller in _scoreControllerList) {

View File

@@ -0,0 +1,269 @@
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/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';
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: 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.caboPenalty,
minValue: 0,
maxValue: 50,
step: 1,
onChanged: (newCaboPenalty) {
setState(() {
ConfigService.setCaboPenalty(newCaboPenalty);
ConfigService.caboPenalty = newCaboPenalty;
});
},
),
),
CustomFormRow(
prefixText: AppLocalizations.of(context).point_limit,
prefixIcon: FontAwesomeIcons.bullseye,
suffixWidget: CustomStepper(
key: _stepperKey2,
initialValue: ConfigService.pointLimit,
minValue: 30,
maxValue: 1000,
step: 10,
onChanged: (newPointLimit) {
setState(() {
ConfigService.setPointLimit(newPointLimit);
ConfigService.pointLimit = newPointLimit;
});
},
),
),
CustomFormRow(
prefixText:
AppLocalizations.of(context).reset_to_default,
prefixIcon: CupertinoIcons.arrow_counterclockwise,
onPressed: () {
ConfigService.resetConfig();
setState(() {
_stepperKey1 = UniqueKey();
_stepperKey2 = UniqueKey();
});
},
)
])),
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).privacy_policy,
prefixIcon: CupertinoIcons.doc_append,
onPressed: () =>
launchUrl(Uri.parse(Constants.kPrivacyPolicyLink)),
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,7 +1,7 @@
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_view.dart';
import 'package:cabo_counter/presentation/views/main_menu_view.dart';
import 'package:flutter/cupertino.dart';
class TabView extends StatefulWidget {
@@ -39,7 +39,7 @@ class _TabViewState extends State<TabView> {
if (index == 0) {
return const MainMenuView();
} else {
return const InformationView();
return const AboutView();
}
});
},

View File

@@ -0,0 +1,53 @@
import 'package:cabo_counter/core/custom_theme.dart';
import 'package:cabo_counter/presentation/widgets/custom_stepper.dart';
import 'package:flutter/cupertino.dart';
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,13 @@
import 'package:cabo_counter/core/custom_theme.dart';
import 'package:flutter/cupertino.dart'; // Für iOS-Style
class Stepper extends StatefulWidget {
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 +18,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 +35,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

@@ -21,7 +21,7 @@ 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 getGameDataAsJsonFile() {
static String _getGameDataAsJsonFile() {
final jsonFile =
gameManager.gameList.map((session) => session.toJson()).toList();
return json.encode(jsonFile);
@@ -39,7 +39,7 @@ class LocalStorageService {
print('[local_storage_service.dart] Versuche, Daten zu speichern...');
try {
final file = await _getFilePath();
final jsonFile = getGameDataAsJsonFile();
final jsonFile = _getGameDataAsJsonFile();
await file.writeAsString(jsonFile);
print(
'[local_storage_service.dart] Die Spieldaten wurden zwischengespeichert.');
@@ -70,7 +70,7 @@ class LocalStorageService {
return false;
}
if (!await validateJsonSchema(jsonString, true)) {
if (!await _validateJsonSchema(jsonString, true)) {
print(
'[local_storage_service.dart] Die Datei konnte nicht validiert werden');
gameManager.gameList = [];
@@ -105,7 +105,7 @@ class LocalStorageService {
/// 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(
static Future<bool> _exportJsonData(
String jsonString,
String fileName,
) async {
@@ -133,16 +133,16 @@ class LocalStorageService {
/// Opens the file picker to export all game sessions as a JSON file.
static Future<bool> exportGameData() async {
String jsonString = getGameDataAsJsonFile();
String jsonString = _getGameDataAsJsonFile();
String fileName = 'cabo_counter-game_data';
return exportJsonData(jsonString, fileName);
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);
return _exportJsonData(jsonString, fileName);
}
/// Opens the file picker to import a JSON file and loads the game data from it.
@@ -162,7 +162,7 @@ class LocalStorageService {
try {
final jsonString = await _readFileContent(path.files.single);
if (await validateJsonSchema(jsonString, true)) {
if (await _validateJsonSchema(jsonString, true)) {
// Checks if the JSON String is in the gameList format
final jsonData = json.decode(jsonString) as List<dynamic>;
@@ -172,12 +172,12 @@ class LocalStorageService {
.toList();
for (GameSession s in importedList) {
importSession(s);
_importSession(s);
}
} else if (await validateJsonSchema(jsonString, false)) {
} 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));
_importSession(GameSession.fromJson(jsonData));
} else {
return ImportStatus.validationError;
}
@@ -198,7 +198,7 @@ class LocalStorageService {
}
/// Imports a single game session into the gameList.
static Future<void> importSession(GameSession session) async {
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.');
@@ -221,7 +221,7 @@ class LocalStorageService {
/// 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(
static Future<bool> _validateJsonSchema(
String jsonString, bool isGameList) async {
final String schemaString;

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,3 +0,0 @@
class Globals {
static String appDevPhase = 'Beta';
}

View File

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

View File

@@ -1,267 +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: ConfigService.caboPenalty,
minValue: 0,
maxValue: 50,
step: 1,
onChanged: (newCaboPenalty) {
setState(() {
ConfigService.setCaboPenalty(newCaboPenalty);
ConfigService.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: ConfigService.pointLimit,
minValue: 30,
maxValue: 1000,
step: 10,
onChanged: (newPointLimit) {
setState(() {
ConfigService.setPointLimit(newPointLimit);
ConfigService.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();
showFeedbackDialog(success);
}),
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.exportGameData();
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),
),
],
),
);
}
},
),
],
)),
)
],
),
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();
}
void showFeedbackDialog(ImportStatus status) {
if (status == ImportStatus.canceled) return;
final (title, message) = _getDialogContent(status);
showCupertinoDialog(
context: context,
builder: (context) {
return CupertinoAlertDialog(
title: Text(title),
content: Text(message),
actions: [
CupertinoDialogAction(
child: Text(AppLocalizations.of(context).ok),
onPressed: () => Navigator.pop(context),
),
],
);
});
}
(String, String) _getDialogContent(ImportStatus status) {
switch (status) {
case ImportStatus.success:
return (
AppLocalizations.of(context).import_success_title,
AppLocalizations.of(context).import_success_message
);
case ImportStatus.validationError:
return (
AppLocalizations.of(context).import_validation_error_title,
AppLocalizations.of(context).import_validation_error_message
);
case ImportStatus.formatError:
return (
AppLocalizations.of(context).import_format_error_title,
AppLocalizations.of(context).import_format_error_message
);
case ImportStatus.genericError:
return (
AppLocalizations.of(context).import_generic_error_title,
AppLocalizations.of(context).import_generic_error_message
);
case ImportStatus.canceled:
return ('', '');
}
}
}

View File

@@ -2,7 +2,7 @@ name: cabo_counter
description: "Mobile app for the card game Cabo"
publish_to: 'none'
version: 0.3.9+331
version: 0.4.4+485
environment:
sdk: ^3.5.4
@@ -27,6 +27,7 @@ dependencies:
intl: any
syncfusion_flutter_charts: ^30.1.37
uuid: ^4.5.1
rate_my_app: ^2.3.2
dev_dependencies:
flutter_test: