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

@@ -0,0 +1,78 @@
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 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: 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'),
)),
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(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,379 @@
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/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: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> {
late final GameSession gameSession;
@override
void initState() {
super.initState();
gameSession = widget.gameSession;
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: gameSession,
builder: (context, _) {
List<int> sortedPlayerIndices = _getSortedPlayerIndices();
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(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: gameSession.players.length,
itemBuilder: (BuildContext context, int index) {
int playerIndex = sortedPlayerIndices[index];
return CupertinoListTile(
title: Row(
children: [
_getPlacementPrefix(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(index + 1);
},
));
},
),
Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 0, 0),
child: Text(
AppLocalizations.of(context).game,
style: CustomTheme.rowTitle,
),
),
Column(
children: [
CupertinoListTile(
title: Text(
AppLocalizations.of(context).game_process,
),
backgroundColorActivated:
CustomTheme.backgroundColor,
onTap: () => Navigator.push(
context,
CupertinoPageRoute(
builder: (_) => GraphView(
gameSession: gameSession,
)))),
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.pushReplacement(
context,
CupertinoPageRoute(
builder: (_) => CreateGameView(
gameTitle: gameSession.gameTitle,
isPointsLimitEnabled: widget
.gameSession
.isPointsLimitEnabled,
players: gameSession.players,
)));
},
),
CupertinoListTile(
title: Text(
AppLocalizations.of(context).export_game,
),
backgroundColorActivated:
CustomTheme.backgroundColor,
onTap: () async {
final success = await LocalStorageService
.exportSingleGameSession(
widget.gameSession);
if (!success && context.mounted) {
showCupertinoDialog(
context: context,
builder: (context) => CupertinoAlertDialog(
title: Text(AppLocalizations.of(context)
.export_error_title),
content: Text(AppLocalizations.of(context)
.export_error_message),
actions: [
CupertinoDialogAction(
child: Text(
AppLocalizations.of(context).ok),
onPressed: () =>
Navigator.pop(context),
),
],
),
);
}
}),
],
)
],
),
),
));
});
}
/// Shows a dialog to confirm ending the game.
/// If the user confirms, it calls the `endGame` method on the game manager
void _showEndGameDialog() {
showCupertinoDialog(
context: context,
builder: (BuildContext context) {
return CupertinoAlertDialog(
title: Text(AppLocalizations.of(context).end_game_title),
content: Text(AppLocalizations.of(context).end_game_message),
actions: [
CupertinoDialogAction(
child: Text(
AppLocalizations.of(context).end_game,
style: const TextStyle(
fontWeight: FontWeight.bold,
color: CupertinoColors.destructiveRed),
),
onPressed: () {
setState(() {
gameManager.endGame(gameSession.id);
});
Navigator.pop(context);
},
),
CupertinoDialogAction(
child: Text(AppLocalizations.of(context).cancel),
onPressed: () => Navigator.pop(context),
),
],
);
},
);
}
/// Returns a list of player indices sorted by their scores in
/// ascending order.
List<int> _getSortedPlayerIndices() {
List<int> playerIndices =
List<int>.generate(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];
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),
);
}
}
Future<bool> _showDeleteGameDialog() async {
return await showCupertinoDialog<bool>(
context: context,
builder: (BuildContext context) {
return CupertinoAlertDialog(
title: Text(AppLocalizations.of(context).delete_game_title),
content: Text(
AppLocalizations.of(context)
.delete_game_message(gameSession.gameTitle),
),
actions: [
CupertinoDialogAction(
child: Text(AppLocalizations.of(context).cancel),
onPressed: () => Navigator.pop(context, false),
),
CupertinoDialogAction(
child: Text(
AppLocalizations.of(context).delete,
style: const TextStyle(
fontWeight: FontWeight.bold, color: Colors.red),
),
onPressed: () {
Navigator.pop(context, true);
},
),
],
);
},
) ??
false;
}
Future<void> _removeGameSession(GameSession gameSession) async {
if (gameManager.gameExistsInGameList(gameSession.id)) {
Navigator.pop(context);
WidgetsBinding.instance.addPostFrameCallback((_) {
gameManager.removeGameSessionById(gameSession.id);
});
} else {
showCupertinoDialog(
context: context,
builder: (BuildContext context) {
return CupertinoAlertDialog(
title: Text(AppLocalizations.of(context).id_error_title),
content: Text(AppLocalizations.of(context).id_error_message),
actions: [
CupertinoDialogAction(
child: Text(AppLocalizations.of(context).ok),
onPressed: () => Navigator.pop(context),
),
],
);
});
}
}
/// Recursively opens the RoundView for the specified round number.
/// It starts with the given [roundNumber] and continues to open the next round
/// until the user navigates back or the round number is invalid.
void _openRoundView(int roundNumber) async {
final val = await Navigator.of(context, rootNavigator: true).push(
CupertinoPageRoute(
fullscreenDialog: true,
builder: (context) => RoundView(
gameSession: gameSession,
roundNumber: roundNumber,
),
),
);
if (val != null && val >= 0) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
await Future.delayed(const Duration(milliseconds: 600));
_openRoundView(val);
});
}
}
}

View File

@@ -0,0 +1,349 @@
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/active_game_view.dart';
import 'package:cabo_counter/presentation/views/mode_selection_view.dart';
import 'package:cabo_counter/services/config_service.dart';
import 'package:flutter/cupertino.dart';
enum CreateStatus {
noGameTitle,
noModeSelected,
minPlayers,
maxPlayers,
noPlayerName,
}
class CreateGameView extends StatefulWidget {
final String? gameTitle;
final bool? isPointsLimitEnabled;
final List<String>? players;
const CreateGameView({
super.key,
this.gameTitle,
this.isPointsLimitEnabled,
this.players,
});
@override
// ignore: library_private_types_in_public_api
_CreateGameViewState createState() => _CreateGameViewState();
}
class _CreateGameViewState extends State<CreateGameView> {
final List<TextEditingController> _playerNameTextControllers = [
TextEditingController()
];
final TextEditingController _gameTitleTextController =
TextEditingController();
/// Maximum number of players allowed in the game.
final int maxPlayers = 5;
/// Variable to store whether the points limit feature is enabled.
bool? _isPointsLimitEnabled;
@override
void initState() {
super.initState();
_isPointsLimitEnabled = widget.isPointsLimitEnabled;
_gameTitleTextController.text = widget.gameTitle ?? '';
if (widget.players != null) {
_playerNameTextControllers.clear();
for (var player in widget.players!) {
_playerNameTextControllers.add(TextEditingController(text: player));
}
}
}
@override
Widget build(BuildContext context) {
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(
_isPointsLimitEnabled == null
? AppLocalizations.of(context).select_mode
: (_isPointsLimitEnabled!
? '${ConfigService.pointLimit} ${AppLocalizations.of(context).points}'
: AppLocalizations.of(context).unlimited),
),
const SizedBox(width: 3),
const CupertinoListTileChevron(),
],
),
onTap: () async {
final selectedMode = await Navigator.push(
context,
CupertinoPageRoute(
builder: (context) => ModeSelectionMenu(
pointLimit: ConfigService.pointLimit,
),
),
);
if (selectedMode != null) {
setState(() {
_isPointsLimitEnabled = selectedMode;
});
}
},
),
),
Padding(
padding: 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 {
showFeedbackDialog(CreateStatus.maxPlayers);
}
},
),
);
} 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],
maxLength: 12,
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 == '') {
showFeedbackDialog(CreateStatus.noGameTitle);
return;
}
if (_isPointsLimitEnabled == null) {
showFeedbackDialog(CreateStatus.noModeSelected);
return;
}
if (_playerNameTextControllers.length < 2) {
showFeedbackDialog(CreateStatus.minPlayers);
return;
}
if (!everyPlayerHasAName()) {
showFeedbackDialog(CreateStatus.noPlayerName);
return;
}
List<String> players = [];
for (var controller in _playerNameTextControllers) {
players.add(controller.text);
}
GameSession gameSession = GameSession(
createdAt: DateTime.now(),
gameTitle: _gameTitleTextController.text,
players: players,
pointLimit: ConfigService.pointLimit,
caboPenalty: ConfigService.caboPenalty,
isPointsLimitEnabled: _isPointsLimitEnabled!,
);
final index = await gameManager.addGameSession(gameSession);
final session = gameManager.gameList[index];
if (context.mounted) {
Navigator.pushReplacement(
context,
CupertinoPageRoute(
builder: (context) =>
ActiveGameView(gameSession: session)));
}
},
),
),
],
))));
}
/// Displays a feedback dialog based on the [CreateStatus].
void showFeedbackDialog(CreateStatus status) {
final (title, message) = _getDialogContent(status);
showCupertinoDialog(
context: context,
builder: (context) {
return CupertinoAlertDialog(
title: Text(title),
content: Text(message),
actions: [
CupertinoDialogAction(
child: Text(AppLocalizations.of(context).ok),
onPressed: () => Navigator.pop(context),
),
],
);
});
}
/// Returns the title and message for the dialog based on the [CreateStatus].
(String, String) _getDialogContent(CreateStatus status) {
switch (status) {
case CreateStatus.noGameTitle:
return (
AppLocalizations.of(context).no_gameTitle_title,
AppLocalizations.of(context).no_gameTitle_message
);
case CreateStatus.noModeSelected:
return (
AppLocalizations.of(context).no_mode_title,
AppLocalizations.of(context).no_mode_message
);
case CreateStatus.minPlayers:
return (
AppLocalizations.of(context).min_players_title,
AppLocalizations.of(context).min_players_message
);
case CreateStatus.maxPlayers:
return (
AppLocalizations.of(context).max_players_title,
AppLocalizations.of(context).max_players_message
);
case CreateStatus.noPlayerName:
return (
AppLocalizations.of(context).no_name_title,
AppLocalizations.of(context).no_name_message
);
}
}
/// Checks if every player has a name.
/// Returns true if all players have a name, false otherwise.
bool everyPlayerHasAName() {
for (var controller in _playerNameTextControllers) {
if (controller.text == '') {
return false;
}
}
return true;
}
@override
void dispose() {
_gameTitleTextController.dispose();
for (var controller in _playerNameTextControllers) {
controller.dispose();
}
super.dispose();
}
}

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

@@ -0,0 +1,366 @@
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/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: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});
@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 {
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: const Text('Cabo Counter'),
trailing: IconButton(
onPressed: () => Navigator.push(
context,
CupertinoPageRoute(
builder: (context) => const CreateGameView(),
),
),
icon: const Icon(CupertinoIcons.add)),
),
child: CupertinoPageScaffold(
child: SafeArea(
child: _isLoading
? const Center(child: CupertinoActivityIndicator())
: gameManager.gameList.isEmpty
? Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 30),
Center(
child: GestureDetector(
onTap: () => Navigator.push(
context,
CupertinoPageRoute(
builder: (context) =>
const CreateGameView(),
),
),
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),
),
),
],
)
: 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.centerRight,
padding:
const EdgeInsets.only(right: 20.0),
child: const Icon(
CupertinoIcons.delete,
color: CupertinoColors.white,
),
),
direction: DismissDirection.endToStart,
confirmDismiss: (direction) async {
final String gameTitle = gameManager
.gameList[index].gameTitle;
return await _showDeleteGamePopup(
context, gameTitle);
},
onDismissed: (direction) {
gameManager
.removeGameSessionByIndex(index);
},
dismissThresholds: const {
DismissDirection.startToEnd: 0.6
},
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 10.0),
child: CupertinoListTile(
backgroundColorActivated:
CustomTheme.backgroundColor,
title: Text(session.gameTitle),
subtitle:
session.isGameFinished == true
? Text(
'\u{1F947} ${session.winner}',
style: const TextStyle(
fontSize: 14),
)
: Text(
'${AppLocalizations.of(context).mode}: ${_translateGameMode(session.isPointsLimitEnabled)}',
style: const TextStyle(
fontSize: 14),
),
trailing: Row(
children: [
Text('${session.roundNumber}'),
const SizedBox(width: 3),
const Icon(CupertinoIcons
.arrow_2_circlepath_circle_fill),
const SizedBox(width: 15),
Text('${session.players.length}'),
const SizedBox(width: 3),
const Icon(
CupertinoIcons.person_2_fill),
],
),
onTap: () {
final session =
gameManager.gameList[index];
Navigator.push(
context,
CupertinoPageRoute(
builder: (context) =>
ActiveGameView(
gameSession: session),
),
).then((_) {
setState(() {});
});
},
),
),
);
});
},
),
),
),
);
});
}
/// 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 '${ConfigService.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: 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(
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,52 @@
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 {
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

@@ -0,0 +1,402 @@
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/services/local_storage_service.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,
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: [
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: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 6,
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
name,
textAlign: TextAlign.center,
maxLines: 1,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
),
),
);
})),
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: [
Expanded(
child: Text(
name,
overflow: TextOverflow.ellipsis,
))
]),
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);
}
: null,
child: Text(AppLocalizations.of(context).done),
),
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),
),
],
),
);
} 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

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

@@ -0,0 +1,48 @@
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 {
const TabView({super.key});
@override
// ignore: library_private_types_in_public_api
_TabViewState createState() => _TabViewState();
}
class _TabViewState extends State<TabView> {
@override
Widget build(BuildContext context) {
return CupertinoTabScaffold(
tabBar: CupertinoTabBar(
backgroundColor: CustomTheme.backgroundTintColor,
iconSize: 27,
height: 55,
items: <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: const Icon(
CupertinoIcons.house_fill,
),
label: AppLocalizations.of(context).home,
),
BottomNavigationBarItem(
icon: const Icon(
CupertinoIcons.info,
),
label: AppLocalizations.of(context).about,
),
]),
tabBuilder: (BuildContext context, int index) {
return CupertinoTabView(builder: (BuildContext context) {
if (index == 0) {
return const MainMenuView();
} else {
return const AboutView();
}
});
},
);
}
}