Merge pull request #127 from flixcoo/feature/126-popup-for-winner

Popup for winner
This commit is contained in:
2025-07-20 23:40:01 +02:00
committed by GitHub
12 changed files with 572 additions and 410 deletions

View File

@@ -19,4 +19,13 @@ class Constants {
remindDays: 45,
minLaunches: 15,
remindLaunches: 40);
/// Delay in milliseconds before a pop-up appears.
static const int popUpDelay = 300;
/// Delay in milliseconds before the round view appears after the previous one is closed.
static const int roundViewDelay = 600;
/// Duration in milliseconds for the fade-in animation of texts.
static const int fadeInDuration = 300;
}

View File

@@ -299,15 +299,19 @@ class GameSession extends ChangeNotifier {
/// It iterates through the player scores and finds the player
/// with the lowest score.
void _setWinner() {
int score = playerScores[0];
String lowestPlayer = players[0];
int minScore = playerScores.reduce((a, b) => a < b ? a : b);
List<String> lowestPlayers = [];
for (int i = 0; i < players.length; i++) {
if (playerScores[i] < score) {
score = playerScores[i];
lowestPlayer = players[i];
if (playerScores[i] == minScore) {
lowestPlayers.add(players[i]);
}
}
winner = lowestPlayer;
if (lowestPlayers.length > 1) {
winner =
'${lowestPlayers.sublist(0, lowestPlayers.length - 1).join(', ')} & ${lowestPlayers.last}';
} else {
winner = lowestPlayers.first;
}
notifyListeners();
}

View File

@@ -96,6 +96,21 @@
}
},
"end_of_game_title": "Spiel beendet",
"end_of_game_message": "{playerCount, plural, =1{{names} hat das Spiel mit {points} Punkten gewonnen. Glückwunsch!} other{{names} haben das Spiel mit {points} Punkten gewonnen. Glückwunsch!}}",
"@end_of_game_message": {
"placeholders": {
"playerCount": {
"type": "int"
},
"names": {
"type": "String"
},
"points": {
"type": "int"
}
}
},
"end_game": "Spiel beenden",
"delete_game": "Spiel löschen",
"new_game_same_settings": "Neues Spiel mit gleichen Einstellungen",

View File

@@ -96,6 +96,21 @@
}
},
"end_of_game_title": "End of Game",
"end_of_game_message": "{names} won the game with {points} points. Congratulations!",
"@end_of_game_message": {
"placeholders": {
"playerCount": {
"type": "int"
},
"names": {
"type": "String"
},
"points": {
"type": "int"
}
}
},
"end_game": "End Game",
"delete_game": "Delete Game",
"new_game_same_settings": "New Game with same Settings",

View File

@@ -453,6 +453,18 @@ abstract class AppLocalizations {
String bonus_points_message(
int playerCount, String names, int pointLimit, int bonusPoints);
/// No description provided for @end_of_game_title.
///
/// In de, this message translates to:
/// **'Spiel beendet'**
String get end_of_game_title;
/// No description provided for @end_of_game_message.
///
/// In de, this message translates to:
/// **'{playerCount, plural, =1{{names} hat das Spiel mit {points} Punkten gewonnen. Glückwunsch!} other{{names} haben das Spiel mit {points} Punkten gewonnen. Glückwunsch!}}'**
String end_of_game_message(int playerCount, String names, int points);
/// No description provided for @end_game.
///
/// In de, this message translates to:

View File

@@ -208,6 +208,21 @@ class AppLocalizationsDe extends AppLocalizations {
return '$_temp0';
}
@override
String get end_of_game_title => 'Spiel beendet';
@override
String end_of_game_message(int playerCount, String names, int points) {
String _temp0 = intl.Intl.pluralLogic(
playerCount,
locale: localeName,
other:
'$names haben das Spiel mit $points Punkten gewonnen. Glückwunsch!',
one: '$names hat das Spiel mit $points Punkten gewonnen. Glückwunsch!',
);
return '$_temp0';
}
@override
String get end_game => 'Spiel beenden';

View File

@@ -205,6 +205,14 @@ class AppLocalizationsEn extends AppLocalizations {
return '$_temp0';
}
@override
String get end_of_game_title => 'End of Game';
@override
String end_of_game_message(int playerCount, String names, int points) {
return '$names won the game with $points points. Congratulations!';
}
@override
String get end_game => 'End Game';

View File

@@ -1,3 +1,4 @@
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';
@@ -8,6 +9,8 @@ import 'package:cabo_counter/presentation/views/mode_selection_view.dart';
import 'package:cabo_counter/presentation/views/points_view.dart';
import 'package:cabo_counter/presentation/views/round_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';
@@ -21,6 +24,9 @@ class ActiveGameView extends StatefulWidget {
}
class _ActiveGameViewState extends State<ActiveGameView> {
final confettiController = ConfettiController(
duration: const Duration(seconds: 10),
);
late final GameSession gameSession;
late List<int> denseRanks;
late List<int> sortedPlayerIndices;
@@ -33,7 +39,9 @@ class _ActiveGameViewState extends State<ActiveGameView> {
@override
Widget build(BuildContext context) {
return ListenableBuilder(
return Stack(
children: [
ListenableBuilder(
listenable: gameSession,
builder: (context, _) {
sortedPlayerIndices = _getSortedPlayerIndices();
@@ -76,7 +84,8 @@ class _ActiveGameViewState extends State<ActiveGameView> {
trailing: Row(
children: [
const SizedBox(width: 5),
Text('${gameSession.playerScores[playerIndex]} '
Text(
'${gameSession.playerScores[playerIndex]} '
'${AppLocalizations.of(context).points}')
],
),
@@ -103,15 +112,15 @@ class _ActiveGameViewState extends State<ActiveGameView> {
title: Text(
'${AppLocalizations.of(context).round} ${index + 1}',
),
trailing:
index + 1 != gameSession.roundNumber ||
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(index + 1);
_openRoundView(context, index + 1);
},
));
},
@@ -127,7 +136,8 @@ class _ActiveGameViewState extends State<ActiveGameView> {
children: [
CupertinoListTile(
title: Text(
AppLocalizations.of(context).scoring_history,
AppLocalizations.of(context)
.scoring_history,
),
backgroundColorActivated:
CustomTheme.backgroundColor,
@@ -168,7 +178,8 @@ class _ActiveGameViewState extends State<ActiveGameView> {
style: gameSession.roundNumber > 1 &&
!gameSession.isGameFinished
? const TextStyle(color: Colors.white)
: const TextStyle(color: Colors.white30),
: const TextStyle(
color: Colors.white30),
),
backgroundColorActivated:
CustomTheme.backgroundColor,
@@ -205,7 +216,8 @@ class _ActiveGameViewState extends State<ActiveGameView> {
context,
CupertinoPageRoute(
builder: (_) => CreateGameView(
gameTitle: gameSession.gameTitle,
gameTitle:
gameSession.gameTitle,
gameMode: widget.gameSession
.isPointsLimitEnabled ==
true
@@ -228,15 +240,19 @@ class _ActiveGameViewState extends State<ActiveGameView> {
if (!success && context.mounted) {
showCupertinoDialog(
context: context,
builder: (context) => CupertinoAlertDialog(
title: Text(AppLocalizations.of(context)
builder: (context) =>
CupertinoAlertDialog(
title: Text(
AppLocalizations.of(context)
.export_error_title),
content: Text(AppLocalizations.of(context)
content: Text(
AppLocalizations.of(context)
.export_error_message),
actions: [
CupertinoDialogAction(
child: Text(
AppLocalizations.of(context).ok),
AppLocalizations.of(context)
.ok),
onPressed: () =>
Navigator.pop(context),
),
@@ -251,7 +267,26 @@ class _ActiveGameViewState extends State<ActiveGameView> {
),
),
));
});
}),
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.
@@ -403,8 +438,8 @@ class _ActiveGameViewState extends State<ActiveGameView> {
/// 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(int roundNumber) async {
final val = await Navigator.of(context, rootNavigator: true).push(
void _openRoundView(BuildContext context, int roundNumber) async {
final round = await Navigator.of(context, rootNavigator: true).push(
CupertinoPageRoute(
fullscreenDialog: true,
builder: (context) => RoundView(
@@ -413,11 +448,58 @@ class _ActiveGameViewState extends State<ActiveGameView> {
),
),
);
if (val != null && val >= 0) {
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: 600));
_openRoundView(val);
await Future.delayed(
const Duration(milliseconds: Constants.roundViewDelay));
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.popUpDelay));
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

@@ -1,3 +1,4 @@
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';
@@ -187,7 +188,8 @@ class _CreateGameViewState extends State<CreateGameView> {
opacity: _playerNameTextControllers.length > 1
? 1.0
: 0.0,
duration: const Duration(milliseconds: 300),
duration: const Duration(
milliseconds: Constants.fadeInDuration),
child: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: ReorderableDragStartListener(

View File

@@ -30,8 +30,28 @@ class _GraphViewState extends State<GraphView> {
middle: Text(AppLocalizations.of(context).scoring_history),
previousPageTitle: AppLocalizations.of(context).back,
),
child: widget.gameSession.roundNumber > 1
? Padding(
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: Padding(
padding: const EdgeInsets.fromLTRB(0, 100, 0, 0),
child: SfCartesianChart(
enableAxisAnimation: true,
@@ -60,24 +80,7 @@ class _GraphViewState extends State<GraphView> {
),
series: getCumulativeScores(),
),
)
: 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),
),
),
],
));
}

View File

@@ -85,10 +85,96 @@ class _MainMenuViewState extends State<MainMenuView> {
),
child: CupertinoPageScaffold(
child: SafeArea(
child: _isLoading
? const Center(child: CupertinoActivityIndicator())
: gameManager.gameList.isEmpty
? Column(
child: Visibility(
visible: _isLoading,
replacement: Visibility(
visible: gameManager.gameList.isEmpty,
replacement: ListView.separated(
itemCount: gameManager.gameList.length,
separatorBuilder: (context, index) => Divider(
height: 1,
thickness: 0.5,
color: CustomTheme.white.withAlpha(50),
indent: 50,
endIndent: 50,
),
itemBuilder: (context, index) {
final session = gameManager.gameList[index];
return ListenableBuilder(
listenable: session,
builder: (context, _) {
return Dismissible(
key: Key(session.id),
background: Container(
color: CupertinoColors.destructiveRed,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20.0),
child: const Icon(
CupertinoIcons.delete,
color: CupertinoColors.white,
),
),
direction: DismissDirection.endToStart,
confirmDismiss: (direction) async {
return await _showDeleteGamePopup(
context, session.gameTitle);
},
onDismissed: (direction) {
gameManager.removeGameSessionById(session.id);
},
dismissThresholds: const {
DismissDirection.startToEnd: 0.6
},
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 10.0),
child: CupertinoListTile(
backgroundColorActivated:
CustomTheme.backgroundColor,
title: Text(session.gameTitle),
subtitle: Visibility(
visible: session.isGameFinished,
replacement: Text(
'${AppLocalizations.of(context).mode}: ${_translateGameMode(session.isPointsLimitEnabled)}',
style: const TextStyle(fontSize: 14),
),
child: Text(
'\u{1F947} ${session.winner}',
style: const TextStyle(fontSize: 14),
)),
trailing: Row(
children: [
Text('${session.roundNumber}'),
const SizedBox(width: 3),
const Icon(CupertinoIcons
.arrow_2_circlepath_circle_fill),
const SizedBox(width: 15),
Text('${session.players.length}'),
const SizedBox(width: 3),
const Icon(
CupertinoIcons.person_2_fill),
],
),
onTap: () {
final session =
gameManager.gameList[index];
Navigator.push(
context,
CupertinoPageRoute(
builder: (context) => ActiveGameView(
gameSession: session),
),
).then((_) {
setState(() {});
});
},
),
),
);
});
},
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 30),
@@ -109,8 +195,7 @@ class _MainMenuViewState extends State<MainMenuView> {
)),
const SizedBox(height: 10),
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 70),
padding: const EdgeInsets.symmetric(horizontal: 70),
child: Text(
'${AppLocalizations.of(context).empty_text_1}\n${AppLocalizations.of(context).empty_text_2}',
textAlign: TextAlign.center,
@@ -118,100 +203,11 @@ class _MainMenuViewState extends State<MainMenuView> {
),
),
],
)
: ListView.separated(
itemCount: gameManager.gameList.length,
separatorBuilder: (context, index) => Divider(
height: 1,
thickness: 0.5,
color: CustomTheme.white.withAlpha(50),
indent: 50,
endIndent: 50,
),
itemBuilder: (context, index) {
final session = gameManager.gameList[index];
return ListenableBuilder(
listenable: session,
builder: (context, _) {
return Dismissible(
key: Key(session.id),
background: Container(
color: CupertinoColors.destructiveRed,
alignment: Alignment.centerRight,
padding:
const EdgeInsets.only(right: 20.0),
child: const Icon(
CupertinoIcons.delete,
color: CupertinoColors.white,
),
),
direction: DismissDirection.endToStart,
confirmDismiss: (direction) async {
return await _showDeleteGamePopup(
context, session.gameTitle);
},
onDismissed: (direction) {
gameManager
.removeGameSessionById(session.id);
},
dismissThresholds: const {
DismissDirection.startToEnd: 0.6
},
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 10.0),
child: CupertinoListTile(
backgroundColorActivated:
CustomTheme.backgroundColor,
title: Text(session.gameTitle),
subtitle:
session.isGameFinished == true
? Text(
'\u{1F947} ${session.winner}',
style: const TextStyle(
fontSize: 14),
)
: Text(
'${AppLocalizations.of(context).mode}: ${_translateGameMode(session.isPointsLimitEnabled)}',
style: const TextStyle(
fontSize: 14),
child: const Center(child: CupertinoActivityIndicator()),
),
trailing: Row(
children: [
Text('${session.roundNumber}'),
const SizedBox(width: 3),
const Icon(CupertinoIcons
.arrow_2_circlepath_circle_fill),
const SizedBox(width: 15),
Text('${session.players.length}'),
const SizedBox(width: 3),
const Icon(
CupertinoIcons.person_2_fill),
],
),
onTap: () {
final session =
gameManager.gameList[index];
Navigator.push(
context,
CupertinoPageRoute(
builder: (context) =>
ActiveGameView(
gameSession: session),
),
).then((_) {
setState(() {});
});
},
),
),
);
});
},
),
),
),
);
)));
});
}
@@ -243,7 +239,7 @@ class _MainMenuViewState extends State<MainMenuView> {
BadRatingDialogDecision badRatingDecision = BadRatingDialogDecision.cancel;
// so that the bad rating dialog is not shown immediately
await Future.delayed(const Duration(milliseconds: 300));
await Future.delayed(const Duration(milliseconds: Constants.popUpDelay));
switch (preRatingDecision) {
case PreRatingDialogDecision.yes:

View File

@@ -2,7 +2,7 @@ name: cabo_counter
description: "Mobile app for the card game Cabo"
publish_to: 'none'
version: 0.5.1+568
version: 0.5.2+581
environment:
sdk: ^3.5.4
@@ -30,6 +30,7 @@ dependencies:
rate_my_app: ^2.3.2
reorderables: ^0.4.2
collection: ^1.18.0
confetti: ^0.6.0
dev_dependencies:
flutter_test: