import 'package:cabo_counter/core/custom_theme.dart'; import 'package:cabo_counter/data/game_session.dart'; import 'package:cabo_counter/l10n/generated/app_localizations.dart'; import 'package:cabo_counter/presentation/widgets/custom_button.dart'; import 'package:cabo_counter/services/local_storage_service.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/services.dart'; import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 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 { /// 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 _scoreControllerList = List.generate( widget.gameSession.players.length, (index) => TextEditingController(), ); /// List of focus nodes for the score text fields. late final List _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, -1) }, child: Text(AppLocalizations.of(context).cancel), ), middle: Text(AppLocalizations.of(context).results), trailing: Visibility( visible: widget.gameSession.isGameFinished, child: const Icon( CupertinoIcons.lock, size: 25, ))), child: 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( 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: CustomButton( onPressed: () async { if (await _showKamikazeSheet(context)) { if (!context.mounted) return; _endOfRoundNavigation(context, true); } }, child: Text(AppLocalizations.of(context).kamikaze, style: TextStyle( color: CustomTheme.kamikazeColor, )), ), ), ), ], ), ), ), ), 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. /// Returns 0 in the first round, as there is no previous round. int _getPreviousRoundWinnerIndex() { if (widget.roundNumber == 1) { return 0; // If it's the first round, the order should be the same as the players list. } final List scores = widget.gameSession.roundList[widget.roundNumber - 2].scoreUpdates; final int winnerIndex = scores.indexOf(0); // Fallback if no player has 0 points, which should not happen in a valid game. if (winnerIndex == -1) { return 0; } return winnerIndex; } /// Rotates the players list based on the previous round's winner. List _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 _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 _showKamikazeSheet(BuildContext context) async { return await showCupertinoModalPopup( context: context, builder: (BuildContext context) { return CupertinoActionSheet( title: Text(AppLocalizations.of(context).kamikaze), message: Text(AppLocalizations.of(context).who_has_kamikaze), actions: widget.gameSession.players.asMap().entries.map((entry) { final index = entry.key; final name = entry.value; return CupertinoActionSheetAction( onPressed: () { _kamikazePlayerIndex = index; Navigator.pop(context, true); }, child: Text( name, style: TextStyle(color: CustomTheme.kamikazeColor), ), ); }).toList(), cancelButton: CupertinoActionSheetAction( onPressed: () => Navigator.pop(context, false), isDestructiveAction: true, child: Text(AppLocalizations.of(context).cancel), ), ); }, ) ?? false; } /// Focuses the next text field in the list of text fields. /// [index] is the index of the current text field. void _focusNextTextfield(int index) { final originalIndices = _getOriginalIndices(); final currentPos = originalIndices.indexOf(index); if (currentPos < originalIndices.length - 1) { 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 _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 roundScores = []; for (TextEditingController c in _scoreControllerList) { if (c.text.isNotEmpty) roundScores.add(int.parse(c.text)); } widget.gameSession.calculateScoredPoints( widget.roundNumber, roundScores, _caboPlayerIndex); } List 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 _showBonusPopup( BuildContext context, List 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 bonusPlayers) { List 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 _endOfRoundNavigation( BuildContext context, bool navigateToNextRound) async { List 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(); } }