558 lines
22 KiB
Dart
558 lines
22 KiB
Dart
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';
|
|
|
|
/// 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 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,
|
|
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();
|
|
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,
|
|
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]).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();
|
|
}
|
|
}
|