Merge branch 'develop' into enhance/34-improvement-for-visual-hierachy
# Conflicts: # lib/presentation/views/create_game_view.dart # pubspec.yaml
This commit is contained in:
		
							
								
								
									
										78
									
								
								lib/presentation/views/about_view.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								lib/presentation/views/about_view.dart
									
									
									
									
									
										Normal 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)), | ||||
|               ], | ||||
|             ), | ||||
|           ], | ||||
|         ))); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										423
									
								
								lib/presentation/views/active_game_view.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										423
									
								
								lib/presentation/views/active_game_view.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,423 @@ | ||||
| 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/mode_selection_view.dart'; | ||||
| import 'package:cabo_counter/presentation/views/points_view.dart'; | ||||
| import 'package:cabo_counter/presentation/views/round_view.dart'; | ||||
| import 'package:cabo_counter/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; | ||||
|   late List<int> denseRanks; | ||||
|   late List<int> sortedPlayerIndices; | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     gameSession = widget.gameSession; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return ListenableBuilder( | ||||
|         listenable: gameSession, | ||||
|         builder: (context, _) { | ||||
|           sortedPlayerIndices = _getSortedPlayerIndices(); | ||||
|           denseRanks = _calculateDenseRank( | ||||
|               gameSession.playerScores, sortedPlayerIndices); | ||||
|           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: [ | ||||
|                                 _getPlacementTextWidget(index), | ||||
|                                 const SizedBox(width: 5), | ||||
|                                 Text( | ||||
|                                   gameSession.players[playerIndex], | ||||
|                                   style: const TextStyle( | ||||
|                                       fontWeight: FontWeight.bold), | ||||
|                                 ), | ||||
|                               ], | ||||
|                             ), | ||||
|                             trailing: Row( | ||||
|                               children: [ | ||||
|                                 const SizedBox(width: 5), | ||||
|                                 Text('${gameSession.playerScores[playerIndex]} ' | ||||
|                                     '${AppLocalizations.of(context).points}') | ||||
|                               ], | ||||
|                             ), | ||||
|                           ); | ||||
|                         }, | ||||
|                       ), | ||||
|                       Padding( | ||||
|                         padding: const EdgeInsets.fromLTRB(10, 10, 0, 0), | ||||
|                         child: Text( | ||||
|                           AppLocalizations.of(context).rounds, | ||||
|                           style: CustomTheme.rowTitle, | ||||
|                         ), | ||||
|                       ), | ||||
|                       ListView.builder( | ||||
|                         shrinkWrap: true, | ||||
|                         physics: const NeverScrollableScrollPhysics(), | ||||
|                         itemCount: gameSession.roundNumber, | ||||
|                         itemBuilder: (BuildContext context, int index) { | ||||
|                           return Padding( | ||||
|                               padding: const EdgeInsets.all(1), | ||||
|                               child: CupertinoListTile( | ||||
|                                 backgroundColorActivated: | ||||
|                                     CustomTheme.backgroundColor, | ||||
|                                 title: Text( | ||||
|                                   '${AppLocalizations.of(context).round} ${index + 1}', | ||||
|                                 ), | ||||
|                                 trailing: | ||||
|                                     index + 1 != gameSession.roundNumber || | ||||
|                                             gameSession.isGameFinished == true | ||||
|                                         ? (const Text('\u{2705}', | ||||
|                                             style: TextStyle(fontSize: 22))) | ||||
|                                         : const Text('\u{23F3}', | ||||
|                                             style: TextStyle(fontSize: 22)), | ||||
|                                 onTap: () async { | ||||
|                                   _openRoundView(index + 1); | ||||
|                                 }, | ||||
|                               )); | ||||
|                         }, | ||||
|                       ), | ||||
|                       Padding( | ||||
|                         padding: const EdgeInsets.fromLTRB(10, 10, 0, 0), | ||||
|                         child: Text( | ||||
|                           AppLocalizations.of(context).statistics, | ||||
|                           style: CustomTheme.rowTitle, | ||||
|                         ), | ||||
|                       ), | ||||
|                       Column( | ||||
|                         children: [ | ||||
|                           CupertinoListTile( | ||||
|                               title: Text( | ||||
|                                 AppLocalizations.of(context).scoring_history, | ||||
|                               ), | ||||
|                               backgroundColorActivated: | ||||
|                                   CustomTheme.backgroundColor, | ||||
|                               onTap: () => Navigator.push( | ||||
|                                   context, | ||||
|                                   CupertinoPageRoute( | ||||
|                                       builder: (_) => GraphView( | ||||
|                                             gameSession: gameSession, | ||||
|                                           )))), | ||||
|                           CupertinoListTile( | ||||
|                               title: Text( | ||||
|                                 AppLocalizations.of(context).point_overview, | ||||
|                               ), | ||||
|                               backgroundColorActivated: | ||||
|                                   CustomTheme.backgroundColor, | ||||
|                               onTap: () => Navigator.push( | ||||
|                                   context, | ||||
|                                   CupertinoPageRoute( | ||||
|                                       builder: (_) => PointsView( | ||||
|                                             gameSession: gameSession, | ||||
|                                           )))), | ||||
|                         ], | ||||
|                       ), | ||||
|                       Padding( | ||||
|                         padding: const EdgeInsets.fromLTRB(10, 10, 0, 0), | ||||
|                         child: Text( | ||||
|                           AppLocalizations.of(context).game, | ||||
|                           style: CustomTheme.rowTitle, | ||||
|                         ), | ||||
|                       ), | ||||
|                       Column( | ||||
|                         children: [ | ||||
|                           Visibility( | ||||
|                             visible: !gameSession.isPointsLimitEnabled, | ||||
|                             child: CupertinoListTile( | ||||
|                                 title: Text( | ||||
|                                   AppLocalizations.of(context).end_game, | ||||
|                                   style: gameSession.roundNumber > 1 && | ||||
|                                           !gameSession.isGameFinished | ||||
|                                       ? const TextStyle(color: Colors.white) | ||||
|                                       : const TextStyle(color: Colors.white30), | ||||
|                                 ), | ||||
|                                 backgroundColorActivated: | ||||
|                                     CustomTheme.backgroundColor, | ||||
|                                 onTap: () { | ||||
|                                   if (gameSession.roundNumber > 1 && | ||||
|                                       !gameSession.isGameFinished) { | ||||
|                                     _showEndGameDialog(); | ||||
|                                   } | ||||
|                                 }), | ||||
|                           ), | ||||
|                           CupertinoListTile( | ||||
|                             title: Text( | ||||
|                               AppLocalizations.of(context).delete_game, | ||||
|                             ), | ||||
|                             backgroundColorActivated: | ||||
|                                 CustomTheme.backgroundColor, | ||||
|                             onTap: () { | ||||
|                               _showDeleteGameDialog().then((value) { | ||||
|                                 if (value) { | ||||
|                                   _removeGameSession(gameSession); | ||||
|                                 } | ||||
|                               }); | ||||
|                             }, | ||||
|                           ), | ||||
|                           CupertinoListTile( | ||||
|                             title: Text( | ||||
|                               AppLocalizations.of(context) | ||||
|                                   .new_game_same_settings, | ||||
|                             ), | ||||
|                             backgroundColorActivated: | ||||
|                                 CustomTheme.backgroundColor, | ||||
|                             onTap: () { | ||||
|                               Navigator.pushReplacement( | ||||
|                                   context, | ||||
|                                   CupertinoPageRoute( | ||||
|                                       builder: (_) => CreateGameView( | ||||
|                                             gameTitle: gameSession.gameTitle, | ||||
|                                             gameMode: widget.gameSession | ||||
|                                                         .isPointsLimitEnabled == | ||||
|                                                     true | ||||
|                                                 ? GameMode.pointLimit | ||||
|                                                 : GameMode.unlimited, | ||||
|                                             players: gameSession.players, | ||||
|                                           ))); | ||||
|                             }, | ||||
|                           ), | ||||
|                           CupertinoListTile( | ||||
|                               title: Text( | ||||
|                                 AppLocalizations.of(context).export_game, | ||||
|                               ), | ||||
|                               backgroundColorActivated: | ||||
|                                   CustomTheme.backgroundColor, | ||||
|                               onTap: () async { | ||||
|                                 final success = await LocalStorageService | ||||
|                                     .exportSingleGameSession( | ||||
|                                         widget.gameSession); | ||||
|                                 if (!success && context.mounted) { | ||||
|                                   showCupertinoDialog( | ||||
|                                     context: context, | ||||
|                                     builder: (context) => CupertinoAlertDialog( | ||||
|                                       title: Text(AppLocalizations.of(context) | ||||
|                                           .export_error_title), | ||||
|                                       content: Text(AppLocalizations.of(context) | ||||
|                                           .export_error_message), | ||||
|                                       actions: [ | ||||
|                                         CupertinoDialogAction( | ||||
|                                           child: Text( | ||||
|                                               AppLocalizations.of(context).ok), | ||||
|                                           onPressed: () => | ||||
|                                               Navigator.pop(context), | ||||
|                                         ), | ||||
|                                       ], | ||||
|                                     ), | ||||
|                                   ); | ||||
|                                 } | ||||
|                               }), | ||||
|                         ], | ||||
|                       ) | ||||
|                     ], | ||||
|                   ), | ||||
|                 ), | ||||
|               )); | ||||
|         }); | ||||
|   } | ||||
|  | ||||
|   /// 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]; | ||||
|       if (scoreA != scoreB) { | ||||
|         return scoreA.compareTo(scoreB); | ||||
|       } | ||||
|       return a.compareTo(b); | ||||
|     }); | ||||
|     return playerIndices; | ||||
|   } | ||||
|  | ||||
|   /// Calculates the dense rank for a player based on their index in the sorted list of players. | ||||
|   List<int> _calculateDenseRank( | ||||
|       List<int> playerScores, List<int> sortedIndices) { | ||||
|     List<int> denseRanks = []; | ||||
|     int rank = 1; | ||||
|     for (int i = 0; i < sortedIndices.length; i++) { | ||||
|       if (i > 0) { | ||||
|         int prevScore = playerScores[sortedIndices[i - 1]]; | ||||
|         int currScore = playerScores[sortedIndices[i]]; | ||||
|         if (currScore != prevScore) { | ||||
|           rank++; | ||||
|         } | ||||
|       } | ||||
|       denseRanks.add(rank); | ||||
|     } | ||||
|     return denseRanks; | ||||
|   } | ||||
|  | ||||
|   /// Returns a text widget representing the placement text based on the given placement number. | ||||
|   /// [index] is the index of the player in [players] list, | ||||
|   Text _getPlacementTextWidget(int index) { | ||||
|     int placement = denseRanks[index]; | ||||
|     switch (placement) { | ||||
|       case 1: | ||||
|         return const Text('\u{1F947}', style: TextStyle(fontSize: 22)); // 🥇 | ||||
|       case 2: | ||||
|         return const Text('\u{1F948}', style: TextStyle(fontSize: 22)); // 🥈 | ||||
|       case 3: | ||||
|         return const Text('\u{1F949}', style: TextStyle(fontSize: 22)); // 🥉 | ||||
|       default: | ||||
|         return Text(' $placement.', | ||||
|             style: const TextStyle(fontWeight: FontWeight.bold)); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /// Shows a dialog to confirm deleting the game session. | ||||
|   Future<bool> _showDeleteGameDialog() async { | ||||
|     return await showCupertinoDialog<bool>( | ||||
|           context: context, | ||||
|           builder: (BuildContext context) { | ||||
|             return CupertinoAlertDialog( | ||||
|               title: Text(AppLocalizations.of(context).delete_game_title), | ||||
|               content: Text( | ||||
|                 AppLocalizations.of(context) | ||||
|                     .delete_game_message(gameSession.gameTitle), | ||||
|               ), | ||||
|               actions: [ | ||||
|                 CupertinoDialogAction( | ||||
|                   child: Text(AppLocalizations.of(context).cancel), | ||||
|                   onPressed: () => Navigator.pop(context, false), | ||||
|                 ), | ||||
|                 CupertinoDialogAction( | ||||
|                   child: Text( | ||||
|                     AppLocalizations.of(context).delete, | ||||
|                     style: const TextStyle( | ||||
|                         fontWeight: FontWeight.bold, color: Colors.red), | ||||
|                   ), | ||||
|                   onPressed: () { | ||||
|                     Navigator.pop(context, true); | ||||
|                   }, | ||||
|                 ), | ||||
|               ], | ||||
|             ); | ||||
|           }, | ||||
|         ) ?? | ||||
|         false; | ||||
|   } | ||||
|  | ||||
|   /// Removes the game session in the game manager and navigates back to the previous screen. | ||||
|   /// If the game session does not exist in the game list, it shows an error dialog. | ||||
|   Future<void> _removeGameSession(GameSession gameSession) async { | ||||
|     if (gameManager.gameExistsInGameList(gameSession.id)) { | ||||
|       Navigator.pop(context); | ||||
|  | ||||
|       WidgetsBinding.instance.addPostFrameCallback((_) { | ||||
|         gameManager.removeGameSessionById(gameSession.id); | ||||
|       }); | ||||
|     } else { | ||||
|       showCupertinoDialog( | ||||
|           context: context, | ||||
|           builder: (BuildContext context) { | ||||
|             return CupertinoAlertDialog( | ||||
|               title: Text(AppLocalizations.of(context).id_error_title), | ||||
|               content: Text(AppLocalizations.of(context).id_error_message), | ||||
|               actions: [ | ||||
|                 CupertinoDialogAction( | ||||
|                   child: Text(AppLocalizations.of(context).ok), | ||||
|                   onPressed: () => Navigator.pop(context), | ||||
|                 ), | ||||
|               ], | ||||
|             ); | ||||
|           }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /// Recursively opens the RoundView for the specified round number. | ||||
|   /// It starts with the given [roundNumber] and continues to open the next round | ||||
|   /// until the user navigates back or the round number is invalid. | ||||
|   void _openRoundView(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); | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										416
									
								
								lib/presentation/views/create_game_view.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										416
									
								
								lib/presentation/views/create_game_view.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,416 @@ | ||||
| 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'; | ||||
| import 'package:flutter/material.dart'; | ||||
|  | ||||
| enum CreateStatus { | ||||
|   noGameTitle, | ||||
|   noModeSelected, | ||||
|   minPlayers, | ||||
|   maxPlayers, | ||||
|   noPlayerName, | ||||
| } | ||||
|  | ||||
| class CreateGameView extends StatefulWidget { | ||||
|   final GameMode gameMode; | ||||
|   final String? gameTitle; | ||||
|   final List<String>? players; | ||||
|  | ||||
|   const CreateGameView({ | ||||
|     super.key, | ||||
|     this.gameTitle, | ||||
|     this.players, | ||||
|     required this.gameMode, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   // ignore: library_private_types_in_public_api | ||||
|   _CreateGameViewState createState() => _CreateGameViewState(); | ||||
| } | ||||
|  | ||||
| class _CreateGameViewState extends State<CreateGameView> { | ||||
|   final List<TextEditingController> _playerNameTextControllers = [ | ||||
|     TextEditingController() | ||||
|   ]; | ||||
|   final TextEditingController _gameTitleTextController = | ||||
|       TextEditingController(); | ||||
|  | ||||
|   /// Maximum number of players allowed in the game. | ||||
|   final int maxPlayers = 5; | ||||
|  | ||||
|   /// Variable to hold the selected game mode. | ||||
|   late GameMode gameMode; | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|  | ||||
|     gameMode = widget.gameMode; | ||||
|  | ||||
|     _gameTitleTextController.text = widget.gameTitle ?? ''; | ||||
|  | ||||
|     if (widget.players != null) { | ||||
|       _playerNameTextControllers.clear(); | ||||
|       for (var player in widget.players!) { | ||||
|         _playerNameTextControllers.add(TextEditingController(text: player)); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @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, | ||||
|               ), | ||||
|             ), | ||||
|             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( | ||||
|                       gameMode == GameMode.none | ||||
|                           ? AppLocalizations.of(context).no_mode_selected | ||||
|                           : (gameMode == GameMode.pointLimit | ||||
|                               ? '${ConfigService.getPointLimit()} ${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.getPointLimit(), | ||||
|                         showDeselection: false, | ||||
|                       ), | ||||
|                     ), | ||||
|                   ); | ||||
|  | ||||
|                   setState(() { | ||||
|                     gameMode = selectedMode ?? gameMode; | ||||
|                   }); | ||||
|                 }, | ||||
|               ), | ||||
|             ), | ||||
|             Padding( | ||||
|               padding: const EdgeInsets.fromLTRB(10, 10, 0, 0), | ||||
|               child: Text( | ||||
|                 AppLocalizations.of(context).players, | ||||
|                 style: CustomTheme.rowTitle, | ||||
|               ), | ||||
|             ), | ||||
|             Expanded( | ||||
|               child: ReorderableListView.builder( | ||||
|                 physics: const NeverScrollableScrollPhysics(), | ||||
|                 itemCount: _playerNameTextControllers.length + 2, | ||||
|                 onReorder: (oldIndex, newIndex) { | ||||
|                   if (oldIndex < _playerNameTextControllers.length && | ||||
|                       newIndex <= _playerNameTextControllers.length) { | ||||
|                     setState(() { | ||||
|                       if (newIndex > oldIndex) newIndex--; | ||||
|                       final item = | ||||
|                           _playerNameTextControllers.removeAt(oldIndex); | ||||
|                       _playerNameTextControllers.insert(newIndex, item); | ||||
|                     }); | ||||
|                   } | ||||
|                 }, | ||||
|                 itemBuilder: (context, index) { | ||||
|                   // Create game button | ||||
|                   if (index == _playerNameTextControllers.length + 1) { | ||||
|                     return Container( | ||||
|                       key: const ValueKey('create_game_button'), | ||||
|                       child: CupertinoButton( | ||||
|                         padding: const EdgeInsets.fromLTRB(0, 50, 0, 0), | ||||
|                         child: Row( | ||||
|                           mainAxisAlignment: MainAxisAlignment.center, | ||||
|                           children: [ | ||||
|                             Container( | ||||
|                               decoration: BoxDecoration( | ||||
|                                 borderRadius: BorderRadius.circular(20), | ||||
|                                 color: CustomTheme.primaryColor, | ||||
|                               ), | ||||
|                               padding: const EdgeInsets.symmetric( | ||||
|                                   horizontal: 15, vertical: 8), | ||||
|                               child: Text( | ||||
|                                 AppLocalizations.of(context).create_game, | ||||
|                                 style: TextStyle( | ||||
|                                   color: CustomTheme.backgroundColor, | ||||
|                                 ), | ||||
|                               ), | ||||
|                             ), | ||||
|                           ], | ||||
|                         ), | ||||
|                         onPressed: () { | ||||
|                           _checkAllGameAttributes(); | ||||
|                         }, | ||||
|                       ), | ||||
|                     ); | ||||
|                   } | ||||
|                   // Add player button | ||||
|                   if (index == _playerNameTextControllers.length) { | ||||
|                     return Container( | ||||
|                       key: const ValueKey('add_player_button'), | ||||
|                       child: Padding( | ||||
|                         padding: const EdgeInsets.symmetric( | ||||
|                             vertical: 4.0, horizontal: 10), | ||||
|                         child: CupertinoButton( | ||||
|                           padding: EdgeInsets.zero, | ||||
|                           child: Row( | ||||
|                             mainAxisAlignment: MainAxisAlignment.center, | ||||
|                             children: [ | ||||
|                               Icon( | ||||
|                                 CupertinoIcons.add_circled_solid, | ||||
|                                 color: CustomTheme.primaryColor, | ||||
|                               ), | ||||
|                               const SizedBox(width: 6), | ||||
|                               Text( | ||||
|                                 AppLocalizations.of(context).add_player, | ||||
|                                 style: | ||||
|                                     TextStyle(color: CustomTheme.primaryColor), | ||||
|                               ), | ||||
|                             ), | ||||
|                           ], | ||||
|                         ), | ||||
|                         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 (gameMode == GameMode.none) { | ||||
|                     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); | ||||
|                   } | ||||
|  | ||||
|                   bool isPointsLimitEnabled = gameMode == GameMode.pointLimit; | ||||
|  | ||||
|                   GameSession gameSession = GameSession( | ||||
|                     createdAt: DateTime.now(), | ||||
|                     gameTitle: _gameTitleTextController.text, | ||||
|                     players: players, | ||||
|                     pointLimit: ConfigService.getPointLimit(), | ||||
|                     caboPenalty: ConfigService.getCaboPenalty(), | ||||
|                     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))); | ||||
|                   } | ||||
|                 }, | ||||
|               ), | ||||
|             ), | ||||
|           ], | ||||
|         )))); | ||||
|   Future<void> _createGame() async { | ||||
|     List<String> players = []; | ||||
|     for (var controller in _playerNameTextControllers) { | ||||
|       players.add(controller.text); | ||||
|     } | ||||
|     GameSession gameSession = GameSession( | ||||
|       createdAt: DateTime.now(), | ||||
|       gameTitle: _gameTitleTextController.text, | ||||
|       players: players, | ||||
|       pointLimit: Globals.pointLimit, | ||||
|       caboPenalty: Globals.caboPenalty, | ||||
|       isPointsLimitEnabled: _isPointsLimitEnabled!, | ||||
|     ); | ||||
|     final index = await gameManager.addGameSession(gameSession); | ||||
|     final session = gameManager.gameList[index]; | ||||
|  | ||||
|     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(); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										132
									
								
								lib/presentation/views/graph_view.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								lib/presentation/views/graph_view.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,132 @@ | ||||
| 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).scoring_history), | ||||
|           previousPageTitle: AppLocalizations.of(context).back, | ||||
|         ), | ||||
|         child: widget.gameSession.roundNumber > 1 | ||||
|             ? Padding( | ||||
|                 padding: const EdgeInsets.fromLTRB(0, 100, 0, 0), | ||||
|                 child: SfCartesianChart( | ||||
|                   enableAxisAnimation: true, | ||||
|                   legend: const Legend( | ||||
|                       overflowMode: LegendItemOverflowMode.wrap, | ||||
|                       isVisible: true, | ||||
|                       position: LegendPosition.bottom), | ||||
|                   primaryXAxis: const NumericAxis( | ||||
|                     labelStyle: TextStyle(fontWeight: FontWeight.bold), | ||||
|                     interval: 1, | ||||
|                     decimalPlaces: 0, | ||||
|                   ), | ||||
|                   primaryYAxis: NumericAxis( | ||||
|                     labelStyle: const TextStyle(fontWeight: FontWeight.bold), | ||||
|                     labelAlignment: LabelAlignment.center, | ||||
|                     labelPosition: ChartDataLabelPosition.inside, | ||||
|                     interval: 1, | ||||
|                     decimalPlaces: 0, | ||||
|                     axisLabelFormatter: (AxisLabelRenderDetails details) { | ||||
|                       if (details.value == 0) { | ||||
|                         return ChartAxisLabel('', const TextStyle()); | ||||
|                       } | ||||
|                       return ChartAxisLabel( | ||||
|                           '${details.value.toInt()}', const TextStyle()); | ||||
|                     }, | ||||
|                   ), | ||||
|                   series: getCumulativeScores(), | ||||
|                 ), | ||||
|               ) | ||||
|             : 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], | ||||
|       ); | ||||
|     }); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										372
									
								
								lib/presentation/views/main_menu_view.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										372
									
								
								lib/presentation/views/main_menu_view.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,372 @@ | ||||
| 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: Text(AppLocalizations.of(context).app_name), | ||||
|               trailing: IconButton( | ||||
|                   onPressed: () => Navigator.push( | ||||
|                         context, | ||||
|                         CupertinoPageRoute( | ||||
|                           builder: (context) => CreateGameView( | ||||
|                               gameMode: ConfigService.getGameMode()), | ||||
|                         ), | ||||
|                       ), | ||||
|                   icon: const Icon(CupertinoIcons.add)), | ||||
|             ), | ||||
|             child: CupertinoPageScaffold( | ||||
|               child: SafeArea( | ||||
|                 child: _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) => CreateGameView( | ||||
|                                         gameMode: ConfigService.getGameMode()), | ||||
|                                   ), | ||||
|                                 ), | ||||
|                                 child: Icon( | ||||
|                                   CupertinoIcons.plus, | ||||
|                                   size: 60, | ||||
|                                   color: CustomTheme.primaryColor, | ||||
|                                 ), | ||||
|                               )), | ||||
|                               const SizedBox(height: 10), | ||||
|                               Padding( | ||||
|                                 padding: | ||||
|                                     const EdgeInsets.symmetric(horizontal: 70), | ||||
|                                 child: Text( | ||||
|                                   '${AppLocalizations.of(context).empty_text_1}\n${AppLocalizations.of(context).empty_text_2}', | ||||
|                                   textAlign: TextAlign.center, | ||||
|                                   style: const TextStyle(fontSize: 16), | ||||
|                                 ), | ||||
|                               ), | ||||
|                             ], | ||||
|                           ) | ||||
|                         : ListView.separated( | ||||
|                             itemCount: gameManager.gameList.length, | ||||
|                             separatorBuilder: (context, index) => Divider( | ||||
|                               height: 1, | ||||
|                               thickness: 0.5, | ||||
|                               color: CustomTheme.white.withAlpha(50), | ||||
|                               indent: 50, | ||||
|                               endIndent: 50, | ||||
|                             ), | ||||
|                             itemBuilder: (context, index) { | ||||
|                               final session = gameManager.gameList[index]; | ||||
|                               return ListenableBuilder( | ||||
|                                   listenable: session, | ||||
|                                   builder: (context, _) { | ||||
|                                     return Dismissible( | ||||
|                                       key: Key(session.id), | ||||
|                                       background: Container( | ||||
|                                         color: 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 { | ||||
|                                         return await _showDeleteGamePopup( | ||||
|                                             context, session.gameTitle); | ||||
|                                       }, | ||||
|                                       onDismissed: (direction) { | ||||
|                                         gameManager | ||||
|                                             .removeGameSessionById(session.id); | ||||
|                                       }, | ||||
|                                       dismissThresholds: const { | ||||
|                                         DismissDirection.startToEnd: 0.6 | ||||
|                                       }, | ||||
|                                       child: Padding( | ||||
|                                         padding: const EdgeInsets.symmetric( | ||||
|                                             vertical: 10.0), | ||||
|                                         child: CupertinoListTile( | ||||
|                                           backgroundColorActivated: | ||||
|                                               CustomTheme.backgroundColor, | ||||
|                                           title: Text(session.gameTitle), | ||||
|                                           subtitle: | ||||
|                                               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 isPointLimitEnabled) { | ||||
|     if (isPointLimitEnabled) { | ||||
|       return '${ConfigService.getPointLimit()} ${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(); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										77
									
								
								lib/presentation/views/mode_selection_view.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								lib/presentation/views/mode_selection_view.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,77 @@ | ||||
| import 'package:cabo_counter/core/custom_theme.dart'; | ||||
| import 'package:cabo_counter/l10n/generated/app_localizations.dart'; | ||||
| import 'package:flutter/cupertino.dart'; | ||||
|  | ||||
| enum GameMode { | ||||
|   none, | ||||
|   pointLimit, | ||||
|   unlimited, | ||||
| } | ||||
|  | ||||
| class ModeSelectionMenu extends StatelessWidget { | ||||
|   final int pointLimit; | ||||
|   final bool showDeselection; | ||||
|   const ModeSelectionMenu( | ||||
|       {super.key, required this.pointLimit, required this.showDeselection}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return CupertinoPageScaffold( | ||||
|       navigationBar: CupertinoNavigationBar( | ||||
|         middle: Text(AppLocalizations.of(context).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, GameMode.pointLimit); | ||||
|               }, | ||||
|             ), | ||||
|           ), | ||||
|           Padding( | ||||
|             padding: const EdgeInsets.fromLTRB(0, 16, 0, 0), | ||||
|             child: CupertinoListTile( | ||||
|               title: Text(AppLocalizations.of(context).unlimited, | ||||
|                   style: CustomTheme.modeTitle), | ||||
|               subtitle: Text( | ||||
|                 AppLocalizations.of(context).unlimited_description, | ||||
|                 style: CustomTheme.modeDescription, | ||||
|                 maxLines: 3, | ||||
|               ), | ||||
|               onTap: () { | ||||
|                 Navigator.pop(context, GameMode.unlimited); | ||||
|               }, | ||||
|             ), | ||||
|           ), | ||||
|           Visibility( | ||||
|               visible: showDeselection, | ||||
|               child: Padding( | ||||
|                 padding: const EdgeInsets.fromLTRB(0, 16, 0, 0), | ||||
|                 child: CupertinoListTile( | ||||
|                   title: Text(AppLocalizations.of(context).no_default_mode, | ||||
|                       style: CustomTheme.modeTitle), | ||||
|                   subtitle: Text( | ||||
|                     AppLocalizations.of(context).no_default_description, | ||||
|                     style: CustomTheme.modeDescription, | ||||
|                     maxLines: 3, | ||||
|                   ), | ||||
|                   onTap: () { | ||||
|                     Navigator.pop(context, GameMode.none); | ||||
|                   }, | ||||
|                 ), | ||||
|               )), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										141
									
								
								lib/presentation/views/points_view.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								lib/presentation/views/points_view.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,141 @@ | ||||
| import 'package:cabo_counter/core/custom_theme.dart'; | ||||
| import 'package:cabo_counter/data/game_session.dart'; | ||||
| import 'package:cabo_counter/l10n/generated/app_localizations.dart'; | ||||
| import 'package:flutter/cupertino.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
|  | ||||
| class PointsView extends StatefulWidget { | ||||
|   final GameSession gameSession; | ||||
|  | ||||
|   const PointsView({super.key, required this.gameSession}); | ||||
|  | ||||
|   @override | ||||
|   State<PointsView> createState() => _PointsViewState(); | ||||
| } | ||||
|  | ||||
| class _PointsViewState extends State<PointsView> { | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return CupertinoPageScaffold( | ||||
|       navigationBar: CupertinoNavigationBar( | ||||
|         middle: Text(AppLocalizations.of(context).point_overview), | ||||
|         previousPageTitle: AppLocalizations.of(context).back, | ||||
|       ), | ||||
|       child: SingleChildScrollView( | ||||
|         padding: const EdgeInsets.fromLTRB(0, 100, 0, 0), | ||||
|         child: Padding( | ||||
|           padding: const EdgeInsets.symmetric(horizontal: 8.0), | ||||
|           child: DataTable( | ||||
|             dataRowMinHeight: 60, | ||||
|             dataRowMaxHeight: 60, | ||||
|             dividerThickness: 0.5, | ||||
|             columnSpacing: 20, | ||||
|             columns: [ | ||||
|               const DataColumn( | ||||
|                   numeric: true, | ||||
|                   headingRowAlignment: MainAxisAlignment.center, | ||||
|                   label: Text( | ||||
|                     '#', | ||||
|                     style: TextStyle(fontWeight: FontWeight.bold), | ||||
|                   ), | ||||
|                   columnWidth: IntrinsicColumnWidth(flex: 0.5)), | ||||
|               ...widget.gameSession.players.map( | ||||
|                 (player) => DataColumn( | ||||
|                     label: FittedBox( | ||||
|                         fit: BoxFit.fill, | ||||
|                         child: Text( | ||||
|                           player, | ||||
|                           style: const TextStyle(fontWeight: FontWeight.bold), | ||||
|                         )), | ||||
|                     headingRowAlignment: MainAxisAlignment.center, | ||||
|                     columnWidth: const IntrinsicColumnWidth(flex: 1)), | ||||
|               ), | ||||
|             ], | ||||
|             rows: [ | ||||
|               ...List<DataRow>.generate( | ||||
|                 widget.gameSession.roundList.length, | ||||
|                 (roundIndex) { | ||||
|                   final round = widget.gameSession.roundList[roundIndex]; | ||||
|                   return DataRow( | ||||
|                     cells: [ | ||||
|                       DataCell(Align( | ||||
|                         alignment: Alignment.center, | ||||
|                         child: Text( | ||||
|                           '${roundIndex + 1}', | ||||
|                           style: const TextStyle(fontSize: 20), | ||||
|                         ), | ||||
|                       )), | ||||
|                       ...List.generate(widget.gameSession.players.length, | ||||
|                           (playerIndex) { | ||||
|                         final int score = round.scores[playerIndex]; | ||||
|                         final int update = round.scoreUpdates[playerIndex]; | ||||
|                         final bool saidCabo = | ||||
|                             round.caboPlayerIndex == playerIndex; | ||||
|                         return DataCell( | ||||
|                           Center( | ||||
|                             child: Column( | ||||
|                               mainAxisAlignment: MainAxisAlignment.center, | ||||
|                               children: [ | ||||
|                                 Container( | ||||
|                                   padding: const EdgeInsets.symmetric( | ||||
|                                       horizontal: 6, vertical: 2), | ||||
|                                   decoration: BoxDecoration( | ||||
|                                     color: update <= 0 | ||||
|                                         ? CustomTheme.pointLossColor | ||||
|                                         : CustomTheme.pointGainColor, | ||||
|                                     borderRadius: BorderRadius.circular(8), | ||||
|                                   ), | ||||
|                                   child: Text( | ||||
|                                     '${update >= 0 ? '+' : ''}$update', | ||||
|                                     style: const TextStyle( | ||||
|                                       color: CupertinoColors.white, | ||||
|                                       fontWeight: FontWeight.bold, | ||||
|                                     ), | ||||
|                                   ), | ||||
|                                 ), | ||||
|                                 const SizedBox(height: 4), | ||||
|                                 Text('$score', | ||||
|                                     style: TextStyle( | ||||
|                                       fontWeight: saidCabo | ||||
|                                           ? FontWeight.bold | ||||
|                                           : FontWeight.normal, | ||||
|                                     )), | ||||
|                               ], | ||||
|                             ), | ||||
|                           ), | ||||
|                         ); | ||||
|                       }), | ||||
|                     ], | ||||
|                   ); | ||||
|                 }, | ||||
|               ), | ||||
|               DataRow( | ||||
|                 cells: [ | ||||
|                   const DataCell(Align( | ||||
|                     alignment: Alignment.center, | ||||
|                     child: Text( | ||||
|                       'Σ', | ||||
|                       style: | ||||
|                           TextStyle(fontSize: 25, fontWeight: FontWeight.bold), | ||||
|                     ), | ||||
|                   )), | ||||
|                   ...widget.gameSession.playerScores.map( | ||||
|                     (score) => DataCell( | ||||
|                       Center( | ||||
|                         child: Text( | ||||
|                           '$score', | ||||
|                           style: const TextStyle( | ||||
|                               fontSize: 20, fontWeight: FontWeight.bold), | ||||
|                         ), | ||||
|                       ), | ||||
|                     ), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										534
									
								
								lib/presentation/views/round_view.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										534
									
								
								lib/presentation/views/round_view.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,534 @@ | ||||
| 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'; | ||||
| import 'package:font_awesome_flutter/font_awesome_flutter.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; | ||||
|     final rotatedPlayers = _getRotatedPlayers(); | ||||
|     final originalIndices = _getOriginalIndices(); | ||||
|  | ||||
|     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: Visibility( | ||||
|               visible: widget.gameSession.isGameFinished, | ||||
|               child: const Icon( | ||||
|                 CupertinoIcons.lock, | ||||
|                 size: 25, | ||||
|               ))), | ||||
|       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: 60, | ||||
|                         child: CupertinoSegmentedControl<int>( | ||||
|                           unselectedColor: | ||||
|                               CustomTheme.mainElementBackgroundColor, | ||||
|                           selectedColor: CustomTheme.primaryColor, | ||||
|                           groupValue: _caboPlayerIndex, | ||||
|                           children: Map.fromEntries(widget.gameSession.players | ||||
|                               .asMap() | ||||
|                               .entries | ||||
|                               .map((entry) { | ||||
|                             final index = entry.key; | ||||
|                             final name = entry.value; | ||||
|                             return MapEntry( | ||||
|                               index, | ||||
|                               Padding( | ||||
|                                 padding: const EdgeInsets.symmetric( | ||||
|                                   horizontal: 6, | ||||
|                                   vertical: 8, | ||||
|                                 ), | ||||
|                                 child: FittedBox( | ||||
|                                   fit: BoxFit.scaleDown, | ||||
|                                   child: Text( | ||||
|                                     name, | ||||
|                                     textAlign: TextAlign.center, | ||||
|                                     maxLines: 1, | ||||
|                                     style: const TextStyle( | ||||
|                                       fontWeight: FontWeight.bold, | ||||
|                                     ), | ||||
|                                   ), | ||||
|                                 ), | ||||
|                               ), | ||||
|                             ); | ||||
|                           })), | ||||
|                           onValueChanged: (value) { | ||||
|                             setState(() { | ||||
|                               _caboPlayerIndex = value; | ||||
|                             }); | ||||
|                           }, | ||||
|                         ), | ||||
|                       ), | ||||
|                     ), | ||||
|                     ListView.builder( | ||||
|                       shrinkWrap: true, | ||||
|                       physics: const NeverScrollableScrollPhysics(), | ||||
|                       itemCount: rotatedPlayers.length, | ||||
|                       itemBuilder: (context, index) { | ||||
|                         final originalIndex = originalIndices[index]; | ||||
|                         final name = rotatedPlayers[index]; | ||||
|                         bool shouldShowMedal = | ||||
|                             index == 0 && widget.roundNumber > 1; | ||||
|                         return Padding( | ||||
|                           padding: const EdgeInsets.symmetric( | ||||
|                               vertical: 10, horizontal: 20), | ||||
|                           child: ClipRRect( | ||||
|                             borderRadius: BorderRadius.circular(12), | ||||
|                             child: CupertinoListTile( | ||||
|                               backgroundColor: CustomTheme.playerTileColor, | ||||
|                               title: Row(children: [ | ||||
|                                 Expanded( | ||||
|                                     child: Row(children: [ | ||||
|                                   Text( | ||||
|                                     name, | ||||
|                                     overflow: TextOverflow.ellipsis, | ||||
|                                   ), | ||||
|                                   Visibility( | ||||
|                                     visible: shouldShowMedal, | ||||
|                                     child: const SizedBox(width: 10), | ||||
|                                   ), | ||||
|                                   Visibility( | ||||
|                                       visible: shouldShowMedal, | ||||
|                                       child: const Icon(FontAwesomeIcons.crown, | ||||
|                                           size: 15)) | ||||
|                                 ])) | ||||
|                               ]), | ||||
|                               subtitle: Text( | ||||
|                                   '${widget.gameSession.playerScores[originalIndex]}' | ||||
|                                   ' ${AppLocalizations.of(context).points}'), | ||||
|                               trailing: SizedBox( | ||||
|                                 width: 100, | ||||
|                                 child: CupertinoTextField( | ||||
|                                   maxLength: 3, | ||||
|                                   focusNode: _focusNodeList[originalIndex], | ||||
|                                   keyboardType: | ||||
|                                       const TextInputType.numberWithOptions( | ||||
|                                     signed: true, | ||||
|                                     decimal: false, | ||||
|                                   ), | ||||
|                                   inputFormatters: [ | ||||
|                                     FilteringTextInputFormatter.digitsOnly, | ||||
|                                   ], | ||||
|                                   textInputAction: index == | ||||
|                                           widget.gameSession.players.length - 1 | ||||
|                                       ? TextInputAction.done | ||||
|                                       : TextInputAction.next, | ||||
|                                   controller: | ||||
|                                       _scoreControllerList[originalIndex], | ||||
|                                   placeholder: | ||||
|                                       AppLocalizations.of(context).points, | ||||
|                                   textAlign: TextAlign.center, | ||||
|                                   onSubmitted: (_) => | ||||
|                                       _focusNextTextfield(originalIndex), | ||||
|                                   onChanged: (_) => setState(() {}), | ||||
|                                 ), | ||||
|                               ), | ||||
|                             ), | ||||
|                           ), | ||||
|                         ); | ||||
|                       }, | ||||
|                     ), | ||||
|                     Padding( | ||||
|                       padding: const EdgeInsets.fromLTRB(0, 10, 0, 0), | ||||
|                       child: Center( | ||||
|                         heightFactor: 1, | ||||
|                         child: CupertinoButton( | ||||
|                           sizeStyle: CupertinoButtonSize.medium, | ||||
|                           borderRadius: BorderRadius.circular(12), | ||||
|                           color: CustomTheme.buttonBackgroundColor, | ||||
|                           onPressed: () async { | ||||
|                             if (await _showKamikazeSheet(context)) { | ||||
|                               if (!context.mounted) return; | ||||
|                               _endOfRoundNavigation(context, true); | ||||
|                             } | ||||
|                           }, | ||||
|                           child: Text( | ||||
|                             AppLocalizations.of(context).kamikaze, | ||||
|                             style: const TextStyle( | ||||
|                               color: CupertinoColors.destructiveRed, | ||||
|                             ), | ||||
|                           ), | ||||
|                         ), | ||||
|                       ), | ||||
|                     ), | ||||
|                   ], | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|           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.mainElementBackgroundColor, | ||||
|                   child: Row( | ||||
|                     mainAxisAlignment: MainAxisAlignment.spaceEvenly, | ||||
|                     children: [ | ||||
|                       CupertinoButton( | ||||
|                         onPressed: _areRoundInputsValid() | ||||
|                             ? () { | ||||
|                                 _endOfRoundNavigation(context, false); | ||||
|                               } | ||||
|                             : null, | ||||
|                         child: Text(AppLocalizations.of(context).done), | ||||
|                       ), | ||||
|                       if (!widget.gameSession.isGameFinished) | ||||
|                         CupertinoButton( | ||||
|                           onPressed: _areRoundInputsValid() | ||||
|                               ? () { | ||||
|                                   _endOfRoundNavigation(context, true); | ||||
|                                 } | ||||
|                               : null, | ||||
|                           child: Text(AppLocalizations.of(context).next_round), | ||||
|                         ), | ||||
|                     ], | ||||
|                   ), | ||||
|                 ); | ||||
|               } else { | ||||
|                 return const SizedBox.shrink(); | ||||
|               } | ||||
|             }), | ||||
|           ) | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   /// Gets the index of the player who won the previous round. | ||||
|   int _getPreviousRoundWinnerIndex() { | ||||
|     if (widget.roundNumber == 1) { | ||||
|       return 0; // If it's the first round, there's no previous round, so return 0. | ||||
|     } | ||||
|  | ||||
|     final previousRound = widget.gameSession.roundList[widget.roundNumber - 2]; | ||||
|     final scores = previousRound.scoreUpdates; | ||||
|  | ||||
|     // Find the index of the player with the minimum score | ||||
|     int minScore = scores[0]; | ||||
|     int winnerIndex = 0; | ||||
|  | ||||
|     // Iterate through the scores to find the player with the minimum score | ||||
|     for (int i = 1; i < scores.length; i++) { | ||||
|       if (scores[i] < minScore) { | ||||
|         minScore = scores[i]; | ||||
|         winnerIndex = i; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return winnerIndex; | ||||
|   } | ||||
|  | ||||
|   /// Rotates the players list based on the previous round's winner. | ||||
|   List<String> _getRotatedPlayers() { | ||||
|     final winnerIndex = _getPreviousRoundWinnerIndex(); | ||||
|     return [ | ||||
|       widget.gameSession.players[winnerIndex], | ||||
|       ...widget.gameSession.players.sublist(winnerIndex + 1), | ||||
|       ...widget.gameSession.players.sublist(0, winnerIndex) | ||||
|     ]; | ||||
|   } | ||||
|  | ||||
|   /// Gets the original indices of the players by recalculating it from the rotated list. | ||||
|   List<int> _getOriginalIndices() { | ||||
|     final winnerIndex = _getPreviousRoundWinnerIndex(); | ||||
|     return [ | ||||
|       winnerIndex, | ||||
|       ...List.generate(widget.gameSession.players.length - winnerIndex - 1, | ||||
|           (i) => winnerIndex + i + 1), | ||||
|       ...List.generate(winnerIndex, (i) => i) | ||||
|     ]; | ||||
|   } | ||||
|  | ||||
|   /// Shows a Cupertino action sheet to select the player who has Kamikaze. | ||||
|   /// It returns true if a player was selected, false if the action was cancelled. | ||||
|   Future<bool> _showKamikazeSheet(BuildContext context) async { | ||||
|     return await showCupertinoModalPopup<bool?>( | ||||
|           context: context, | ||||
|           builder: (BuildContext context) { | ||||
|             return CupertinoActionSheet( | ||||
|               title: Text(AppLocalizations.of(context).kamikaze), | ||||
|               message: Text(AppLocalizations.of(context).who_has_kamikaze), | ||||
|               actions: widget.gameSession.players.asMap().entries.map((entry) { | ||||
|                 final index = entry.key; | ||||
|                 final name = entry.value; | ||||
|                 return CupertinoActionSheetAction( | ||||
|                   onPressed: () { | ||||
|                     _kamikazePlayerIndex = index; | ||||
|                     Navigator.pop(context, true); | ||||
|                   }, | ||||
|                   child: Text(name), | ||||
|                 ); | ||||
|               }).toList(), | ||||
|               cancelButton: CupertinoActionSheetAction( | ||||
|                 onPressed: () => Navigator.pop(context, false), | ||||
|                 isDestructiveAction: true, | ||||
|                 child: Text(AppLocalizations.of(context).cancel), | ||||
|               ), | ||||
|             ); | ||||
|           }, | ||||
|         ) ?? | ||||
|         false; | ||||
|   } | ||||
|  | ||||
|   /// Focuses the next text field in the list of text fields. | ||||
|   /// [index] is the index of the current text field. | ||||
|   void _focusNextTextfield(int index) { | ||||
|     final originalIndices = _getOriginalIndices(); | ||||
|     final currentPos = originalIndices.indexOf(index); | ||||
|  | ||||
|     if (currentPos < originalIndices.length - 1) { | ||||
|       FocusScope.of(context) | ||||
|           .requestFocus(_focusNodeList[originalIndices[currentPos + 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. | ||||
|   List<int> _finishRound() { | ||||
|     print('===================================='); | ||||
|     print('Runde ${widget.roundNumber} beendet'); | ||||
|     // The shown round is smaller than the newest round | ||||
|     if (widget.roundNumber < widget.gameSession.roundNumber) { | ||||
|       print('Da diese Runde bereits gespielt wurde, werden die alten ' | ||||
|           'Punktestaende ueberschrieben'); | ||||
|     } | ||||
|     if (_kamikazePlayerIndex != null) { | ||||
|       print('${widget.gameSession.players[_kamikazePlayerIndex!]} hat Kamikaze ' | ||||
|           'und bekommt 0 Punkte'); | ||||
|       print('Alle anderen Spieler bekommen 50 Punkte'); | ||||
|       widget.gameSession | ||||
|           .applyKamikaze(widget.roundNumber, _kamikazePlayerIndex!); | ||||
|     } else { | ||||
|       List<int> roundScores = []; | ||||
|       for (TextEditingController c in _scoreControllerList) { | ||||
|         if (c.text.isNotEmpty) roundScores.add(int.parse(c.text)); | ||||
|       } | ||||
|       widget.gameSession.calculateScoredPoints( | ||||
|           widget.roundNumber, roundScores, _caboPlayerIndex); | ||||
|     } | ||||
|     List<int> bonusPlayers = widget.gameSession.updatePoints(); | ||||
|     if (widget.gameSession.isGameFinished == true) { | ||||
|       print('Das Spiel ist beendet'); | ||||
|     } else if (widget.roundNumber == widget.gameSession.roundNumber) { | ||||
|       widget.gameSession.increaseRound(); | ||||
|     } | ||||
|     return bonusPlayers; | ||||
|   } | ||||
|  | ||||
|   /// Shows a popup dialog with the information which player received the bonus points. | ||||
|   Future<void> _showBonusPopup( | ||||
|       BuildContext context, List<int> bonusPlayers) async { | ||||
|     int pointLimit = widget.gameSession.pointLimit; | ||||
|     int bonusPoints = (pointLimit / 2).round(); | ||||
|  | ||||
|     String resultText = | ||||
|         _getBonusPopupMessageString(pointLimit, bonusPoints, bonusPlayers); | ||||
|  | ||||
|     await showCupertinoDialog( | ||||
|       context: context, | ||||
|       builder: (context) => CupertinoAlertDialog( | ||||
|         title: Text(AppLocalizations.of(context).bonus_points_title), | ||||
|         content: Text(resultText), | ||||
|         actions: [ | ||||
|           CupertinoDialogAction( | ||||
|             child: Text(AppLocalizations.of(context).ok), | ||||
|             onPressed: () => Navigator.of(context).pop(), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   /// Generates the message string for the bonus popup. | ||||
|   /// It takes the [pointLimit], [bonusPoints] and the list of [bonusPlayers] | ||||
|   /// and returns a formatted string. | ||||
|   String _getBonusPopupMessageString( | ||||
|       int pointLimit, int bonusPoints, List<int> bonusPlayers) { | ||||
|     List<String> nameList = | ||||
|         bonusPlayers.map((i) => widget.gameSession.players[i]).toList(); | ||||
|     String resultText = ''; | ||||
|     if (nameList.length == 1) { | ||||
|       resultText = AppLocalizations.of(context).bonus_points_message( | ||||
|           nameList.length, nameList.first, pointLimit, bonusPoints); | ||||
|     } else { | ||||
|       resultText = nameList.length == 2 | ||||
|           ? '${nameList[0]} & ${nameList[1]}' | ||||
|           : '${nameList.sublist(0, nameList.length - 1).join(', ')} & ${nameList.last}'; | ||||
|       resultText = AppLocalizations.of(context).bonus_points_message( | ||||
|         nameList.length, | ||||
|         resultText, | ||||
|         pointLimit, | ||||
|         bonusPoints, | ||||
|       ); | ||||
|     } | ||||
|     return resultText; | ||||
|   } | ||||
|  | ||||
|   /// Handles the navigation for the end of the round. | ||||
|   /// It checks for bonus players and shows a popup, saves the game session, | ||||
|   /// and navigates to the next round or back to the previous screen. | ||||
|   /// It takes the BuildContext [context] and a boolean [navigateToNextRound] to determine | ||||
|   /// if it should navigate to the next round or not. | ||||
|   Future<void> _endOfRoundNavigation( | ||||
|       BuildContext context, bool navigateToNextRound) async { | ||||
|     List<int> bonusPlayersIndices = _finishRound(); | ||||
|     if (bonusPlayersIndices.isNotEmpty) { | ||||
|       await _showBonusPopup(context, bonusPlayersIndices); | ||||
|     } | ||||
|  | ||||
|     LocalStorageService.saveGameSessions(); | ||||
|  | ||||
|     if (context.mounted) { | ||||
|       // If the game is finished, pop the context and return to the previous screen. | ||||
|       if (widget.gameSession.isGameFinished) { | ||||
|         Navigator.pop(context); | ||||
|         return; | ||||
|       } | ||||
|       // If navigateToNextRound is false, pop the context and return to the previous screen. | ||||
|       if (!navigateToNextRound) { | ||||
|         Navigator.pop(context); | ||||
|         return; | ||||
|       } | ||||
|       // If navigateToNextRound is true and the game isn't finished yet, | ||||
|       // pop the context and navigate to the next round. | ||||
|       Navigator.pop(context, widget.roundNumber + 1); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     for (final controller in _scoreControllerList) { | ||||
|       controller.dispose(); | ||||
|     } | ||||
|     for (final focusNode in _focusNodeList) { | ||||
|       focusNode.dispose(); | ||||
|     } | ||||
|     super.dispose(); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										304
									
								
								lib/presentation/views/settings_view.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										304
									
								
								lib/presentation/views/settings_view.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,304 @@ | ||||
| import 'package:cabo_counter/core/constants.dart'; | ||||
| import 'package:cabo_counter/core/custom_theme.dart'; | ||||
| import 'package:cabo_counter/l10n/generated/app_localizations.dart'; | ||||
| import 'package:cabo_counter/presentation/views/mode_selection_view.dart'; | ||||
| import 'package:cabo_counter/presentation/widgets/custom_form_row.dart'; | ||||
| import 'package:cabo_counter/presentation/widgets/custom_stepper.dart'; | ||||
| import 'package:cabo_counter/services/config_service.dart'; | ||||
| import 'package:cabo_counter/services/local_storage_service.dart'; | ||||
| import 'package:cabo_counter/services/version_service.dart'; | ||||
| import 'package:flutter/cupertino.dart'; | ||||
| import 'package:font_awesome_flutter/font_awesome_flutter.dart'; | ||||
| import 'package:url_launcher/url_launcher.dart'; | ||||
|  | ||||
| class SettingsView extends StatefulWidget { | ||||
|   const SettingsView({super.key}); | ||||
|  | ||||
|   @override | ||||
|   State<SettingsView> createState() => _SettingsViewState(); | ||||
| } | ||||
|  | ||||
| class _SettingsViewState extends State<SettingsView> { | ||||
|   UniqueKey _stepperKey1 = UniqueKey(); | ||||
|   UniqueKey _stepperKey2 = UniqueKey(); | ||||
|   GameMode defaultMode = ConfigService.getGameMode(); | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return CupertinoPageScaffold( | ||||
|       navigationBar: CupertinoNavigationBar( | ||||
|         middle: Text(AppLocalizations.of(context).settings), | ||||
|       ), | ||||
|       child: SafeArea( | ||||
|           child: SingleChildScrollView( | ||||
|         child: Column( | ||||
|           mainAxisAlignment: MainAxisAlignment.start, | ||||
|           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|           children: [ | ||||
|             Padding( | ||||
|               padding: const EdgeInsets.fromLTRB(10, 10, 0, 0), | ||||
|               child: Text( | ||||
|                 AppLocalizations.of(context).points, | ||||
|                 style: CustomTheme.rowTitle, | ||||
|               ), | ||||
|             ), | ||||
|             Padding( | ||||
|                 padding: const EdgeInsets.fromLTRB(10, 15, 10, 10), | ||||
|                 child: CupertinoFormSection.insetGrouped( | ||||
|                     backgroundColor: CustomTheme.backgroundColor, | ||||
|                     margin: EdgeInsets.zero, | ||||
|                     children: [ | ||||
|                       CustomFormRow( | ||||
|                         prefixText: AppLocalizations.of(context).cabo_penalty, | ||||
|                         prefixIcon: CupertinoIcons.bolt_fill, | ||||
|                         suffixWidget: CustomStepper( | ||||
|                           key: _stepperKey1, | ||||
|                           initialValue: ConfigService.getCaboPenalty(), | ||||
|                           minValue: 0, | ||||
|                           maxValue: 50, | ||||
|                           step: 1, | ||||
|                           onChanged: (newCaboPenalty) { | ||||
|                             setState(() { | ||||
|                               ConfigService.setCaboPenalty(newCaboPenalty); | ||||
|                             }); | ||||
|                           }, | ||||
|                         ), | ||||
|                       ), | ||||
|                       CustomFormRow( | ||||
|                         prefixText: AppLocalizations.of(context).point_limit, | ||||
|                         prefixIcon: FontAwesomeIcons.bullseye, | ||||
|                         suffixWidget: CustomStepper( | ||||
|                           key: _stepperKey2, | ||||
|                           initialValue: ConfigService.getPointLimit(), | ||||
|                           minValue: 30, | ||||
|                           maxValue: 1000, | ||||
|                           step: 10, | ||||
|                           onChanged: (newPointLimit) { | ||||
|                             setState(() { | ||||
|                               ConfigService.setPointLimit(newPointLimit); | ||||
|                             }); | ||||
|                           }, | ||||
|                         ), | ||||
|                       ), | ||||
|                       CustomFormRow( | ||||
|                         prefixText: AppLocalizations.of(context).standard_mode, | ||||
|                         prefixIcon: CupertinoIcons.square_stack, | ||||
|                         suffixWidget: Row( | ||||
|                           mainAxisAlignment: MainAxisAlignment.end, | ||||
|                           children: [ | ||||
|                             Text( | ||||
|                               defaultMode == GameMode.none | ||||
|                                   ? AppLocalizations.of(context).no_default_mode | ||||
|                                   : (defaultMode == GameMode.pointLimit | ||||
|                                       ? '${ConfigService.getPointLimit()} ${AppLocalizations.of(context).points}' | ||||
|                                       : AppLocalizations.of(context).unlimited), | ||||
|                             ), | ||||
|                             const SizedBox(width: 5), | ||||
|                             const CupertinoListTileChevron() | ||||
|                           ], | ||||
|                         ), | ||||
|                         onPressed: () async { | ||||
|                           final selectedMode = await Navigator.push( | ||||
|                             context, | ||||
|                             CupertinoPageRoute( | ||||
|                               builder: (context) => ModeSelectionMenu( | ||||
|                                 pointLimit: ConfigService.getPointLimit(), | ||||
|                                 showDeselection: true, | ||||
|                               ), | ||||
|                             ), | ||||
|                           ); | ||||
|  | ||||
|                           setState(() { | ||||
|                             defaultMode = selectedMode ?? GameMode.none; | ||||
|                           }); | ||||
|                           ConfigService.setGameMode(defaultMode); | ||||
|                         }, | ||||
|                       ), | ||||
|                       CustomFormRow( | ||||
|                         prefixText: | ||||
|                             AppLocalizations.of(context).reset_to_default, | ||||
|                         prefixIcon: CupertinoIcons.arrow_counterclockwise, | ||||
|                         onPressed: () { | ||||
|                           ConfigService.resetConfig(); | ||||
|                           setState(() { | ||||
|                             _stepperKey1 = UniqueKey(); | ||||
|                             _stepperKey2 = UniqueKey(); | ||||
|                             defaultMode = ConfigService.getGameMode(); | ||||
|                           }); | ||||
|                         }, | ||||
|                       ) | ||||
|                     ])), | ||||
|             Padding( | ||||
|               padding: const EdgeInsets.fromLTRB(10, 10, 0, 0), | ||||
|               child: Text( | ||||
|                 AppLocalizations.of(context).game_data, | ||||
|                 style: CustomTheme.rowTitle, | ||||
|               ), | ||||
|             ), | ||||
|             Padding( | ||||
|                 padding: const EdgeInsets.fromLTRB(10, 15, 10, 10), | ||||
|                 child: CupertinoFormSection.insetGrouped( | ||||
|                     backgroundColor: CustomTheme.backgroundColor, | ||||
|                     margin: EdgeInsets.zero, | ||||
|                     children: [ | ||||
|                       CustomFormRow( | ||||
|                         prefixText: AppLocalizations.of(context).import_data, | ||||
|                         prefixIcon: CupertinoIcons.square_arrow_down, | ||||
|                         onPressed: () async { | ||||
|                           final status = | ||||
|                               await LocalStorageService.importJsonFile(); | ||||
|                           showFeedbackDialog(status); | ||||
|                         }, | ||||
|                         suffixWidget: const CupertinoListTileChevron(), | ||||
|                       ), | ||||
|                       CustomFormRow( | ||||
|                         prefixText: AppLocalizations.of(context).export_data, | ||||
|                         prefixIcon: CupertinoIcons.square_arrow_up, | ||||
|                         onPressed: () => LocalStorageService.exportGameData(), | ||||
|                         suffixWidget: const CupertinoListTileChevron(), | ||||
|                       ), | ||||
|                       CustomFormRow( | ||||
|                         prefixText: AppLocalizations.of(context).delete_data, | ||||
|                         prefixIcon: CupertinoIcons.trash, | ||||
|                         onPressed: () => _deleteAllGames(), | ||||
|                       ), | ||||
|                     ])), | ||||
|             Padding( | ||||
|               padding: const EdgeInsets.fromLTRB(10, 10, 0, 0), | ||||
|               child: Text( | ||||
|                 AppLocalizations.of(context).app, | ||||
|                 style: CustomTheme.rowTitle, | ||||
|               ), | ||||
|             ), | ||||
|             Padding( | ||||
|                 padding: const EdgeInsets.fromLTRB(10, 15, 10, 0), | ||||
|                 child: CupertinoFormSection.insetGrouped( | ||||
|                     backgroundColor: CustomTheme.backgroundColor, | ||||
|                     margin: EdgeInsets.zero, | ||||
|                     children: [ | ||||
|                       CustomFormRow( | ||||
|                         prefixText: AppLocalizations.of(context).wiki, | ||||
|                         prefixIcon: CupertinoIcons.book, | ||||
|                         onPressed: () => | ||||
|                             launchUrl(Uri.parse(Constants.kGithubWikiLink)), | ||||
|                         suffixWidget: const CupertinoListTileChevron(), | ||||
|                       ), | ||||
|                       CustomFormRow( | ||||
|                         prefixText: AppLocalizations.of(context).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 ('', ''); | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										48
									
								
								lib/presentation/views/tab_view.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								lib/presentation/views/tab_view.dart
									
									
									
									
									
										Normal 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.mainElementBackgroundColor, | ||||
|           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(); | ||||
|           } | ||||
|         }); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										53
									
								
								lib/presentation/widgets/custom_form_row.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								lib/presentation/widgets/custom_form_row.dart
									
									
									
									
									
										Normal 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, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										76
									
								
								lib/presentation/widgets/custom_stepper.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								lib/presentation/widgets/custom_stepper.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,76 @@ | ||||
| import 'package:cabo_counter/core/custom_theme.dart'; | ||||
| import 'package:flutter/cupertino.dart'; // Für iOS-Style | ||||
|  | ||||
| class CustomStepper extends StatefulWidget { | ||||
|   final int minValue; | ||||
|   final int maxValue; | ||||
|   final int? initialValue; | ||||
|   final int step; | ||||
|   final ValueChanged<int> onChanged; | ||||
|   const CustomStepper({ | ||||
|     super.key, | ||||
|     required this.minValue, | ||||
|     required this.maxValue, | ||||
|     required this.step, | ||||
|     required this.onChanged, | ||||
|     this.initialValue, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   // ignore: library_private_types_in_public_api | ||||
|   _CustomStepperState createState() => _CustomStepperState(); | ||||
| } | ||||
|  | ||||
| class _CustomStepperState extends State<CustomStepper> { | ||||
|   late int _value; | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     final start = widget.initialValue ?? widget.minValue; | ||||
|     _value = start.clamp(widget.minValue, widget.maxValue); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Row( | ||||
|       mainAxisSize: MainAxisSize.min, | ||||
|       mainAxisAlignment: MainAxisAlignment.end, | ||||
|       children: [ | ||||
|         CupertinoButton( | ||||
|           padding: EdgeInsets.zero, | ||||
|           onPressed: _decrement, | ||||
|           child: const Icon(CupertinoIcons.minus), | ||||
|         ), | ||||
|         Padding( | ||||
|           padding: const EdgeInsets.symmetric(horizontal: 12.0), | ||||
|           child: Text('$_value', | ||||
|               style: TextStyle(fontSize: 18, color: CustomTheme.white)), | ||||
|         ), | ||||
|         CupertinoButton( | ||||
|           padding: EdgeInsets.zero, | ||||
|           onPressed: _increment, | ||||
|           child: const Icon(CupertinoIcons.add), | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void _increment() { | ||||
|     if (_value + widget.step <= widget.maxValue) { | ||||
|       setState(() { | ||||
|         _value += widget.step; | ||||
|         widget.onChanged.call(_value); | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   void _decrement() { | ||||
|     if (_value - widget.step >= widget.minValue) { | ||||
|       setState(() { | ||||
|         _value -= widget.step; | ||||
|         widget.onChanged.call(_value); | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user