Files
cabo-counter/lib/presentation/views/home/active_game/round_view.dart

559 lines
22 KiB
Dart

import 'package:cabo_counter/core/custom_theme.dart';
import 'package:cabo_counter/data/dto/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';
/// A view for displaying and managing a single round
///
/// This widget allows users to input and review scores for each player in a round,
/// select the player who called CABO, and handle special cases such as Kamikaze rounds.
/// It manages the round state, validates input, and coordinates navigation between rounds.
///
/// Features:
/// - Rotates player order based on the previous round's winner.
/// - Supports Kamikaze rounds with dedicated UI and logic.
/// - Handles score input, validation, and updates to the game session.
/// - Displays bonus point popups when applicable.
///
/// Requires a [GameSession] and the current [roundNumber].
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(),
);
late List<GlobalKey> _textFieldKeys;
@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;
}
_textFieldKeys = List.generate(
widget.gameSession.players.length,
(index) => GlobalKey(),
);
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(
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: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: EdgeInsets.only(bottom: 20 + 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 player = entry.value;
return MapEntry(
index,
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 8,
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
player.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.getPlayerScoresAsList()[originalIndex]}'
' ${AppLocalizations.of(context).points}'),
trailing: SizedBox(
width: 100,
key: _textFieldKeys[originalIndex],
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,
)),
),
),
),
],
),
),
),
),
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<int> 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<String> _getRotatedPlayers() {
final winnerIndex = _getPreviousRoundWinnerIndex();
final playerList = widget.gameSession.getPlayerNamesAsList();
return [
playerList[winnerIndex],
...playerList.sublist(winnerIndex + 1),
...playerList.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 player = entry.value;
return CupertinoActionSheetAction(
onPressed: () {
_kamikazePlayerIndex = index;
Navigator.pop(context, true);
},
child: Text(
player.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) {
final nextIndex = originalIndices[currentPos + 1];
FocusScope.of(context)
.requestFocus(_focusNodeList[originalIndices[currentPos + 1]]);
final scrollContext = _textFieldKeys[nextIndex].currentContext;
if (scrollContext != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
Scrollable.ensureVisible(
scrollContext,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
alignment: 0.55,
);
});
}
} 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].name).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();
}
}