Reordered views

This commit is contained in:
2025-08-05 14:28:18 +02:00
parent 344aa970cd
commit 07906d61dd
14 changed files with 17 additions and 17 deletions

View File

@@ -0,0 +1,521 @@
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/data/game_session.dart';
import 'package:cabo_counter/l10n/generated/app_localizations.dart';
import 'package:cabo_counter/presentation/views/home/active_game/graph_view.dart';
import 'package:cabo_counter/presentation/views/home/active_game/mode_selection_view.dart';
import 'package:cabo_counter/presentation/views/home/active_game/points_view.dart';
import 'package:cabo_counter/presentation/views/home/active_game/round_view.dart';
import 'package:cabo_counter/presentation/views/home/create_game_view.dart';
import 'package:cabo_counter/services/local_storage_service.dart';
import 'package:collection/collection.dart';
import 'package:confetti/confetti.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> {
/// Constant value to represent a press on the cancel button in round view.
static const int kRoundCancelled = -1;
final confettiController = ConfettiController(
duration: const Duration(seconds: 10),
);
late final GameSession gameSession;
/// A list of the ranks for each player corresponding to their index in sortedPlayerIndices
late List<int> denseRanks;
/// A list of player indices sorted by their scores in ascending order.
late List<int> sortedPlayerIndices;
@override
void initState() {
super.initState();
gameSession = widget.gameSession;
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
ListenableBuilder(
listenable: gameSession,
builder: (context, _) {
sortedPlayerIndices = _getSortedPlayerIndices();
denseRanks = _calculateDenseRank(
gameSession.playerScores, sortedPlayerIndices);
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(
gameSession.gameTitle,
overflow: TextOverflow.ellipsis,
),
),
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(context, 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),
),
],
),
);
}
}),
],
)
],
),
),
));
}),
Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Center(
child: ConfettiWidget(
blastDirectionality: BlastDirectionality.explosive,
particleDrag: 0.07,
emissionFrequency: 0.1,
numberOfParticles: 10,
minBlastForce: 5,
maxBlastForce: 20,
confettiController: confettiController,
),
),
],
),
],
);
}
/// 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(
isDestructiveAction: true,
child: Text(
AppLocalizations.of(context).end_game,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
onPressed: () {
setState(() {
gameManager.endGame(gameSession.id);
_playFinishAnimation(context);
});
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(BuildContext context, int roundNumber) async {
final round = await Navigator.of(context, rootNavigator: true).push(
CupertinoPageRoute(
fullscreenDialog: true,
builder: (context) => RoundView(
gameSession: gameSession,
roundNumber: roundNumber,
),
),
);
// If the user presses the cancel button
if (round == kRoundCancelled) return;
if (widget.gameSession.isGameFinished && context.mounted) {
_playFinishAnimation(context);
}
// If the previous round was not the last one
if (round != null && round >= 0) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
await Future.delayed(
const Duration(milliseconds: Constants.kRoundViewDelay));
if (context.mounted) {
_openRoundView(context, round);
}
});
}
}
/// Plays the confetti animation and shows a dialog with the winner's information.
Future<void> _playFinishAnimation(BuildContext context) async {
String winner = widget.gameSession.winner;
int winnerPoints = widget.gameSession.playerScores.min;
int winnerAmount = winner.contains('&') ? 2 : 1;
confettiController.play();
await Future.delayed(const Duration(milliseconds: Constants.kPopUpDelay));
if (context.mounted) {
showCupertinoDialog(
context: context,
builder: (BuildContext context) {
return CupertinoAlertDialog(
title: Text(AppLocalizations.of(context).end_of_game_title),
content: Text(AppLocalizations.of(context)
.end_of_game_message(winnerAmount, winner, winnerPoints)),
actions: [
CupertinoDialogAction(
child: Text(AppLocalizations.of(context).ok),
onPressed: () {
confettiController.stop();
Navigator.pop(context);
},
),
],
);
});
}
}
@override
void dispose() {
confettiController.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,134 @@
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: SafeArea(
child: Visibility(
visible: widget.gameSession.roundNumber > 1 ||
widget.gameSession.isGameFinished,
replacement: 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),
),
),
],
),
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(),
),
),
));
}
/// 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],
);
});
}
}

View 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);
},
),
)),
],
),
);
}
}

View File

@@ -0,0 +1,255 @@
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: SafeArea(child: LayoutBuilder(builder: (context, constraints) {
const double caboFieldWidthFactor = 0.2;
const double tablePadding = 8;
final int playerCount = widget.gameSession.players.length;
const double roundColWidth = 35;
final double playerColWidth =
(constraints.maxWidth - roundColWidth - (tablePadding)) /
playerCount;
return Column(
children: [
ConstrainedBox(
constraints: BoxConstraints(maxWidth: constraints.maxWidth),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: tablePadding),
child: DataTable(
dataRowMaxHeight: 0,
dataRowMinHeight: 0,
columnSpacing: 0,
horizontalMargin: 0,
columns: [
const DataColumn(
label: SizedBox(
width: roundColWidth,
child: Text(
'#',
style: TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
),
numeric: true,
),
...widget.gameSession.players.map(
(player) => DataColumn(
label: SizedBox(
width: playerColWidth,
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8),
child: Text(
player,
style: const TextStyle(
fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
softWrap: true,
maxLines: 2,
textAlign: TextAlign.center,
),
),
),
),
),
],
rows: const [],
),
),
),
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.vertical,
child: ConstrainedBox(
constraints:
BoxConstraints(maxWidth: constraints.maxWidth),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: tablePadding),
child: DataTable(
dataRowMaxHeight: 75,
dataRowMinHeight: 75,
columnSpacing: 0,
horizontalMargin: 0,
headingRowHeight: 0,
columns: [
const DataColumn(
label: SizedBox(
width: roundColWidth,
child: Text(
'#',
style: TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
),
numeric: true,
),
...widget.gameSession.players.map(
(player) => DataColumn(
label: SizedBox(
width: playerColWidth,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8),
child: Text(
player,
style: const TextStyle(
fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
softWrap: true,
maxLines: 2,
textAlign: TextAlign.center,
),
),
),
),
),
],
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: Padding(
padding: const EdgeInsets.symmetric(
vertical: 6.0),
child: Container(
width: playerColWidth *
(playerCount *
caboFieldWidthFactor), // Adjust width based on amount of players
decoration: BoxDecoration(
color: saidCabo
? CustomTheme
.buttonBackgroundColor
: CupertinoColors.transparent,
borderRadius:
BorderRadius.circular(5),
),
child: Column(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
const SizedBox(
height: 5,
),
Container(
padding: const EdgeInsets
.symmetric(
horizontal: 6,
vertical: 2),
decoration: BoxDecoration(
color: update <= 0
? CustomTheme
.pointLossColor
: CustomTheme
.pointGainColor,
borderRadius:
BorderRadius.circular(
6),
),
child: Text(
'${update >= 0 ? '+' : ''}$update',
style: const TextStyle(
color:
CupertinoColors.white,
fontWeight:
FontWeight.bold,
),
),
),
const SizedBox(height: 4),
Text(
'$score',
style: TextStyle(
color: CustomTheme.white,
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),
),
),
),
),
],
),
],
),
),
)),
),
],
);
})));
}
}

View File

@@ -0,0 +1,527 @@
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<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, -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<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: 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<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) {
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();
}
}