diff --git a/lib/data/game_manager.dart b/lib/data/game_manager.dart index b3a1933..502feb6 100644 --- a/lib/data/game_manager.dart +++ b/lib/data/game_manager.dart @@ -1,5 +1,6 @@ import 'package:cabo_counter/data/game_session.dart'; import 'package:cabo_counter/services/local_storage_service.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; class GameManager extends ChangeNotifier { @@ -10,17 +11,25 @@ class GameManager extends ChangeNotifier { /// sorts the list in descending order based on the creation date, and notifies listeners of the change. /// It also saves the updated game sessions to local storage. /// Returns the index of the newly added session in the sorted list. - Future addGameSession(GameSession session) async { + int addGameSession(GameSession session) { session.addListener(() { notifyListeners(); // Propagate session changes }); gameList.add(session); gameList.sort((a, b) => b.createdAt.compareTo(a.createdAt)); notifyListeners(); - await LocalStorageService.saveGameSessions(); + LocalStorageService.saveGameSessions(); return gameList.indexOf(session); } + /// Retrieves a game session by its id. + /// Takes a String [id] as input. It searches the `gameList` for a session + /// with a matching id and returns it if found. + /// If no session is found, it returns null. + GameSession? getGameSessionById(String id) { + return gameList.firstWhereOrNull((session) => session.id == id); + } + /// Removes a game session from the list and sorts it by creation date. /// Takes a [index] as input. It then removes the session at the specified index from the `gameList`, /// sorts the list in descending order based on the creation date, and notifies listeners of the change. diff --git a/lib/data/game_session.dart b/lib/data/game_session.dart index d1402e5..a9164be 100644 --- a/lib/data/game_session.dart +++ b/lib/data/game_session.dart @@ -13,7 +13,7 @@ import 'package:uuid/uuid.dart'; /// [isGameFinished] is a boolean indicating if the game has ended yet. /// [winner] is the name of the player who won the game. class GameSession extends ChangeNotifier { - late String id; + final String id; final DateTime createdAt; final String gameTitle; final List players; @@ -27,6 +27,7 @@ class GameSession extends ChangeNotifier { List roundList = []; GameSession({ + required this.id, required this.createdAt, required this.gameTitle, required this.players, @@ -35,8 +36,6 @@ class GameSession extends ChangeNotifier { required this.isPointsLimitEnabled, }) { playerScores = List.filled(players.length, 0); - var uuid = const Uuid(); - id = uuid.v1(); } @override diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index c5fae28..effbd96 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -55,7 +55,7 @@ "min_players_title": "Zu wenig Spieler:innen", "min_players_message": "Es müssen mindestens 2 Spieler:innen hinzugefügt werden", "no_name_title": "Kein Name", - "no_name_message": "Jeder Spieler muss einen Namen haben.", + "no_name_message": "Jede:r Spieler:in muss einen Namen haben.", "select_game_mode": "Spielmodus auswählen", "no_mode_selected": "Wähle einen Spielmodus", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index d821b28..9c64cb2 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -365,7 +365,7 @@ abstract class AppLocalizations { /// No description provided for @no_name_message. /// /// In de, this message translates to: - /// **'Jeder Spieler muss einen Namen haben.'** + /// **'Jede:r Spieler:in muss einen Namen haben.'** String get no_name_message; /// No description provided for @select_game_mode. diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 13dcf2a..c4849d8 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -149,7 +149,7 @@ class AppLocalizationsDe extends AppLocalizations { String get no_name_title => 'Kein Name'; @override - String get no_name_message => 'Jeder Spieler muss einen Namen haben.'; + String get no_name_message => 'Jede:r Spieler:in muss einen Namen haben.'; @override String get select_game_mode => 'Spielmodus auswählen'; diff --git a/lib/presentation/views/create_game_view.dart b/lib/presentation/views/create_game_view.dart index e0b4198..870ea29 100644 --- a/lib/presentation/views/create_game_view.dart +++ b/lib/presentation/views/create_game_view.dart @@ -4,8 +4,12 @@ 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/presentation/widgets/custom_button.dart'; import 'package:cabo_counter/services/config_service.dart'; import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; +import 'package:uuid/uuid.dart'; enum CreateStatus { noGameTitle, @@ -42,6 +46,9 @@ class _CreateGameViewState extends State { /// Maximum number of players allowed in the game. final int maxPlayers = 5; + /// Factor to adjust the view length when the keyboard is visible. + final double keyboardHeightAdjustmentFactor = 0.75; + /// Variable to hold the selected game mode. late GameMode gameMode; @@ -64,120 +71,92 @@ class _CreateGameViewState extends State { @override Widget build(BuildContext context) { return CupertinoPageScaffold( + resizeToAvoidBottomInset: false, 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(), - ], + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(10, 10, 0, 0), + child: Text( + AppLocalizations.of(context).game, + style: CustomTheme.rowTitle, ), - 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, + 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, + ), ), - ), - Expanded( - child: ListView.builder( - itemCount: _playerNameTextControllers.length + 1, - itemBuilder: (context, index) { - if (index == _playerNameTextControllers.length) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: CupertinoButton( - padding: EdgeInsets.zero, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - CupertinoIcons.add_circled, - color: CupertinoColors.activeGreen, - size: 25, - ), - const SizedBox(width: 8), - Text( - AppLocalizations.of(context).add_player, - style: const TextStyle( - color: CupertinoColors.activeGreen, - ), - ), - ], + Padding( + padding: const EdgeInsets.fromLTRB(15, 10, 10, 0), + child: CupertinoTextField( + decoration: const BoxDecoration(), + readOnly: true, + prefix: Text(AppLocalizations.of(context).mode), + suffix: Row( + children: [ + _getDisplayedGameMode(), + const SizedBox(width: 3), + const CupertinoListTileChevron(), + ], + ), + onTap: () async { + final selectedMode = await Navigator.push( + context, + CupertinoPageRoute( + builder: (context) => ModeSelectionMenu( + pointLimit: ConfigService.getPointLimit(), + showDeselection: false, ), - onPressed: () { - if (_playerNameTextControllers.length < maxPlayers) { - setState(() { - _playerNameTextControllers - .add(TextEditingController()); - }); - } else { - showFeedbackDialog(CreateStatus.maxPlayers); - } - }, ), ); - } else { - // Spieler-Einträge + + setState(() { + gameMode = selectedMode ?? gameMode; + }); + }, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(10, 10, 0, 0), + child: Text( + AppLocalizations.of(context).players, + style: CustomTheme.rowTitle, + ), + ), + ReorderableListView.builder( + shrinkWrap: true, + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(8), + itemCount: _playerNameTextControllers.length, + onReorder: (oldIndex, newIndex) { + setState(() { + if (oldIndex < _playerNameTextControllers.length && + newIndex <= _playerNameTextControllers.length) { + if (newIndex > oldIndex) newIndex--; + final item = + _playerNameTextControllers.removeAt(oldIndex); + _playerNameTextControllers.insert(newIndex, item); + } + }); + }, + itemBuilder: (context, index) { return Padding( - padding: const EdgeInsets.symmetric( - vertical: 8.0, horizontal: 5), + key: ValueKey(index), + padding: const EdgeInsets.symmetric(vertical: 8.0), child: Row( children: [ CupertinoButton( @@ -204,78 +183,148 @@ class _CreateGameViewState extends State { decoration: const BoxDecoration(), ), ), + AnimatedOpacity( + opacity: _playerNameTextControllers.length > 1 + ? 1.0 + : 0.0, + duration: const Duration(milliseconds: 300), + child: Padding( + padding: const EdgeInsets.only(right: 8.0), + child: ReorderableDragStartListener( + index: index, + child: const Icon( + CupertinoIcons.line_horizontal_3, + color: CupertinoColors.systemGrey, + ), + ), + ), + ) ], ), ); - } - }, + }), + Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 50), + child: Center( + child: SizedBox( + width: double.infinity, + child: CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + AppLocalizations.of(context).add_player, + style: TextStyle(color: CustomTheme.primaryColor), + ), + const SizedBox(width: 8), + Icon( + CupertinoIcons.add_circled_solid, + color: CustomTheme.primaryColor, + size: 25, + ), + ], + ), + onPressed: () { + if (_playerNameTextControllers.length < maxPlayers) { + setState(() { + _playerNameTextControllers + .add(TextEditingController()); + }); + } else { + _showFeedbackDialog(CreateStatus.maxPlayers); + } + }, + ), + ), + ), ), - ), - Center( - child: CupertinoButton( - padding: EdgeInsets.zero, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( + Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 0, 50), + child: Center( + key: const ValueKey('create_game_button'), + child: CustomButton( + child: Text( AppLocalizations.of(context).create_game, - style: const TextStyle( - color: CupertinoColors.activeGreen, + style: TextStyle( + color: CustomTheme.primaryColor, ), ), - ], + onPressed: () { + _checkAllGameAttributes(); + }, + ), ), - 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 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))); - } - }, ), - ), - ], - )))); + KeyboardVisibilityBuilder(builder: (context, visible) { + if (visible) { + return SizedBox( + height: MediaQuery.of(context).viewInsets.bottom * + keyboardHeightAdjustmentFactor, + ); + } else { + return const SizedBox.shrink(); + } + }) + ], + ), + ))); + } + + /// Returns a widget that displays the currently selected game mode in the View. + Text _getDisplayedGameMode() { + if (gameMode == GameMode.none) { + return Text(AppLocalizations.of(context).no_mode_selected); + } else if (gameMode == GameMode.pointLimit) { + return Text( + '${ConfigService.getPointLimit()} ${AppLocalizations.of(context).points}', + style: TextStyle(color: CustomTheme.primaryColor)); + } else { + return Text(AppLocalizations.of(context).unlimited, + style: TextStyle(color: CustomTheme.primaryColor)); + } + } + + /// Checks all game attributes before creating a new game. + /// If any attribute is invalid, it shows a feedback dialog. + /// If all attributes are valid, it calls the `_createGame` method. + void _checkAllGameAttributes() { + if (_gameTitleTextController.text == '') { + _showFeedbackDialog(CreateStatus.noGameTitle); + return; + } + + if (gameMode == GameMode.none) { + _showFeedbackDialog(CreateStatus.noModeSelected); + return; + } + + if (_playerNameTextControllers.length < 2) { + _showFeedbackDialog(CreateStatus.minPlayers); + return; + } + + if (!_everyPlayerHasAName()) { + _showFeedbackDialog(CreateStatus.noPlayerName); + return; + } + + _createGame(); + } + + /// Checks if every player has a name. + /// Returns true if all players have a name, false otherwise. + bool _everyPlayerHasAName() { + for (var controller in _playerNameTextControllers) { + if (controller.text == '') { + return false; + } + } + return true; } /// Displays a feedback dialog based on the [CreateStatus]. - void showFeedbackDialog(CreateStatus status) { + void _showFeedbackDialog(CreateStatus status) { final (title, message) = _getDialogContent(status); showCupertinoDialog( @@ -326,15 +375,36 @@ class _CreateGameViewState extends State { } } - /// Checks if every player has a name. - /// Returns true if all players have a name, false otherwise. - bool everyPlayerHasAName() { + /// Creates a new gameSession and navigates to the active game view. + /// This method creates a new gameSession object with the provided attributes in the text fields. + /// It then adds the game session to the game manager and navigates to the active game view. + void _createGame() { + var uuid = const Uuid(); + final String id = uuid.v1(); + + List players = []; for (var controller in _playerNameTextControllers) { - if (controller.text == '') { - return false; - } + players.add(controller.text); } - return true; + + bool isPointsLimitEnabled = gameMode == GameMode.pointLimit; + + GameSession gameSession = GameSession( + id: id, + createdAt: DateTime.now(), + gameTitle: _gameTitleTextController.text, + players: players, + pointLimit: ConfigService.getPointLimit(), + caboPenalty: ConfigService.getCaboPenalty(), + isPointsLimitEnabled: isPointsLimitEnabled, + ); + gameManager.addGameSession(gameSession); + final session = gameManager.getGameSessionById(id) ?? gameSession; + + Navigator.pushReplacement( + context, + CupertinoPageRoute( + builder: (context) => ActiveGameView(gameSession: session))); } @override diff --git a/lib/presentation/views/round_view.dart b/lib/presentation/views/round_view.dart index f99380e..d2a9da5 100644 --- a/lib/presentation/views/round_view.dart +++ b/lib/presentation/views/round_view.dart @@ -1,6 +1,7 @@ import 'package:cabo_counter/core/custom_theme.dart'; import 'package:cabo_counter/data/game_session.dart'; import 'package:cabo_counter/l10n/generated/app_localizations.dart'; +import 'package:cabo_counter/presentation/widgets/custom_button.dart'; import 'package:cabo_counter/services/local_storage_service.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/services.dart'; @@ -228,10 +229,7 @@ class _RoundViewState extends State { padding: const EdgeInsets.fromLTRB(0, 10, 0, 0), child: Center( heightFactor: 1, - child: CupertinoButton( - sizeStyle: CupertinoButtonSize.medium, - borderRadius: BorderRadius.circular(12), - color: CustomTheme.buttonBackgroundColor, + child: CustomButton( onPressed: () async { if (await _showKamikazeSheet(context)) { if (!context.mounted) return; diff --git a/lib/presentation/views/tab_view.dart b/lib/presentation/views/tab_view.dart index 4b757fa..08b1790 100644 --- a/lib/presentation/views/tab_view.dart +++ b/lib/presentation/views/tab_view.dart @@ -16,6 +16,7 @@ class _TabViewState extends State { @override Widget build(BuildContext context) { return CupertinoTabScaffold( + resizeToAvoidBottomInset: false, tabBar: CupertinoTabBar( backgroundColor: CustomTheme.mainElementBackgroundColor, iconSize: 27, diff --git a/lib/presentation/widgets/custom_button.dart b/lib/presentation/widgets/custom_button.dart new file mode 100644 index 0000000..0feb799 --- /dev/null +++ b/lib/presentation/widgets/custom_button.dart @@ -0,0 +1,19 @@ +import 'package:cabo_counter/core/custom_theme.dart'; +import 'package:flutter/cupertino.dart'; + +class CustomButton extends StatelessWidget { + final Widget child; + final VoidCallback? onPressed; + const CustomButton({super.key, required this.child, this.onPressed}); + + @override + Widget build(BuildContext context) { + return CupertinoButton( + sizeStyle: CupertinoButtonSize.medium, + borderRadius: BorderRadius.circular(12), + color: CustomTheme.buttonBackgroundColor, + onPressed: onPressed, + child: child, + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index e445bd6..c9be09f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: cabo_counter description: "Mobile app for the card game Cabo" publish_to: 'none' -version: 0.5.0+544 +version: 0.5.1+568 environment: sdk: ^3.5.4 @@ -28,6 +28,8 @@ dependencies: syncfusion_flutter_charts: ^30.1.37 uuid: ^4.5.1 rate_my_app: ^2.3.2 + reorderables: ^0.4.2 + collection: ^1.18.0 dev_dependencies: flutter_test: diff --git a/test/data/game_session_test.dart b/test/data/game_session_test.dart index 4ca2158..9654bad 100644 --- a/test/data/game_session_test.dart +++ b/test/data/game_session_test.dart @@ -9,6 +9,7 @@ void main() { setUp(() { session = GameSession( + id: '1', createdAt: testDate, gameTitle: testTitle, players: testPlayers,