diff --git a/lib/const.dart b/lib/const.dart index 9ac2d2e5..b602cf9c 100644 --- a/lib/const.dart +++ b/lib/const.dart @@ -44,4 +44,5 @@ class Const { static const String frenchBeloteScoreDB = 'belote-score'; static const String tarotScoreDB = 'tarot-score'; static const String purchaseDB = 'iap'; + static const String notificationDB = 'notification'; } diff --git a/lib/helpers/correct_instance.dart b/lib/helpers/correct_instance.dart index 32d88dd3..847ffa5a 100644 --- a/lib/helpers/correct_instance.dart +++ b/lib/helpers/correct_instance.dart @@ -1,8 +1,4 @@ -import 'package:carg/models/game/coinche_belote.dart'; -import 'package:carg/models/game/contree_belote.dart'; -import 'package:carg/models/game/french_belote.dart'; -import 'package:carg/models/game/game.dart'; -import 'package:carg/models/game/tarot.dart'; +import 'package:carg/models/game/game_type.dart'; import 'package:carg/services/impl/game/coinche_belote_game_service.dart'; import 'package:carg/services/impl/game/contree_belote_game_service.dart'; import 'package:carg/services/impl/game/french_belote_game_service.dart'; @@ -17,51 +13,51 @@ import 'package:carg/services/impl/score/french_belote_score_service.dart'; import 'package:carg/services/impl/score/tarot_score_service.dart'; class CorrectInstance { - static ofGameService(Game game) { - switch (game.runtimeType) { - case FrenchBelote: + static ofGameService(GameType type) { + switch (type) { + case GameType.BELOTE: return FrenchBeloteGameService(); - case CoincheBelote: + case GameType.COINCHE: return CoincheBeloteGameService(); - case ContreeBelote: + case GameType.CONTREE: return ContreeBeloteGameService(); - case Tarot: + case GameType.TAROT: return TarotGameService(); default: throw Exception( - '${game.runtimeType.toString()} does not have any registered game service'); + '${type.name.toString()} does not have any registered game service'); } } - static ofRoundService(Game game) { - switch (game.runtimeType) { - case FrenchBelote: + static ofRoundService(GameType type) { + switch (type) { + case GameType.BELOTE: return FrenchBeloteRoundService(); - case CoincheBelote: + case GameType.COINCHE: return CoincheBeloteRoundService(); - case ContreeBelote: + case GameType.CONTREE: return ContreeBeloteRoundService(); - case Tarot: + case GameType.TAROT: return TarotRoundService(); default: throw Exception( - '${game.runtimeType.toString()} does not have any registered round service'); + '${type.name.toString()} does not have any registered round service'); } } - static ofScoreService(Game game) { - switch (game.runtimeType) { - case FrenchBelote: + static ofScoreService(GameType type) { + switch (type) { + case GameType.BELOTE: return FrenchBeloteScoreService(); - case CoincheBelote: + case GameType.COINCHE: return CoincheBeloteScoreService(); - case ContreeBelote: + case GameType.CONTREE: return ContreeBeloteScoreService(); - case Tarot: + case GameType.TAROT: return TarotScoreService(); default: throw Exception( - '${game.runtimeType.toString()} does not have any registered score service'); + '${type.name.toString()} does not have any registered score service'); } } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 096f7d1f..c3cf2f24 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,6 +1,7 @@ { "about": "About", "admin": "Admin", + "and": "and", "appDescription": "The application to record your games of Belote, Coinche, Contrée and Tarot!", "attack": "Attack", "back": "Back", @@ -32,7 +33,6 @@ "contractTypeCapot": "Capot", "contractTypeGenerale": "Générale", "contractTypeFailedGenerale": "Failed générale", - "contractTypeNormal": "Contract", "copyId": "Copy the ID", "country": "{count,plural, =0{Country} =1{1 country} other{{count} countries} }", "@country": {"placeholders":{"count": {}}}, @@ -41,6 +41,7 @@ "delete": "Delete", "deleteAll": "Delete all", "dixDeDer": "Dix de der", + "doYouWantToContinue": "Do you want to continue?", "edition": "Edition", "email": "Email", "emailSent": "Login email sent", @@ -50,6 +51,7 @@ "enterOtp": "Please enter the OTP code sent to", "error": "Error", "errorDuringPurchase": "Error during purchase", + "errorGameNotFound": "Game not found", "errorInvalidPhoneNumber": "The phone number is invalid", "errorLoadingPage": "Error encountered while loading the page", "errorPlayerAlreadyLinked": "This player is already linked to another account", @@ -59,6 +61,11 @@ "gameAnticlockwiseDirection": "This card game is played in a anticlockwise direction", "gameClockwiseDirection": "This card game is played in a clockwise direction", "gameDistribution": "Game distribution", + "gameEnded": "Game ended", + "gameEndedNotificationMessage": "A {game_type} game just ended! Do you want to check the scores?", + "@gameEndedNotificationMessage": {"placeholders":{"game_type": {}}}, + "gameEndedNotificationMessageSmall": "A {game_type} game just ended!", + "@gameEndedNotificationMessageSmall": {"placeholders":{"game_type": {}}}, "gameIsStarting": "Game is initializing", "gameNotes": "Game notes", "games": "Games", @@ -66,6 +73,7 @@ "idCopied": "ID copied to clipboard", "information": "Information", "informationAboutTheApp": "Information about the app", + "informationMessage": "Information message", "inProgress": "In progress", "leave": "Leave", "linkPlayer": "Link an existing player to this account", @@ -84,6 +92,8 @@ "messagePlayerDistributeCardsFirsPart": "It's", "messagePlayerDistributeCardsSecondPart": "'s turn to distribute the cards!", "messageSmsInfo": "An SMS containing an OTP will be sent to you. Standard messaging and data rates apply", + "messageStartGame": "{count,plural, =1{You are about to send a game request notification to this player:} other{You are about to send a game request notification to these players:}", + "@messageStartGame": {"placeholders":{"count": {}}}, "messageStopGame": "You are about to complete this game. The winners as well as the losers (shameful) will be designated", "messageWelcomeDescription": "The application that allows you to record your games of Belote, Coinche, Contrée and Tarot!", "myAccount": "My account", @@ -91,11 +101,17 @@ "myProfile": "My profile", "myTeams": "My teams", "newGame": "New game", + "newGameInvite": "You have received a new invitation to a {game_type} game from this player:", + "@newGameInvite": {"placeholders":{"game_type": {}}}, + "newGameInviteSmall": "You have received a new invitation to a {game_type} game!", + "@newGameInviteSmall": {"placeholders":{"game_type": {}}}, + "newGameInviteJoinAsking": "Do you want to join?", "newEmail": "New email address", "newPhoneNumber": "New phone number", "newPhoneNumberValidate": "New phone number validated successfully!", "newPlayer": "New player", "newRound": "New round", + "no": "No", "noEmailProvided": "No email provided", "noGamesYet": "No games yet", "noName": "No name", @@ -104,6 +120,7 @@ "noProducts": "No products are available for purchase", "noScoreYet": "No score yet", "noStatisticYet": "No statistics yet", + "notificationsTitle": "Notifications", "otp": "One-time password (OTP)", "otpSend": "OTP code sent successfully", "or": "or", @@ -169,6 +186,7 @@ "them": "Them", "trickPoints": "Trick points", "type": "Type", + "yes": "Yes", "youDontHaveAnyPlayer": "You don't have any players", "unableAppInfo": "Unable to get application information", "unableToOpen": "Unable to open the web page", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 960e3338..d8656c04 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1,6 +1,7 @@ { "about": "À propos", "admin": "Admin", + "and": "et", "appDescription": "L'application pour enregistrer vos parties de Belote, Coinche, Contrée et Tarot !", "attack": "Attaque", "back": "Retour", @@ -40,6 +41,7 @@ "delete": "Supprimer", "deleteAll": "Tout supprimer", "dixDeDer": "Dix de der", + "doYouWantToContinue": "Voulez-vous continuer ?", "edition": "Édition", "email": "Email", "emailSent": "Email de conexion envoyé", @@ -49,6 +51,7 @@ "enterOtp": "Veuillez entrer le code OTP envoyé au", "error": "Erreur", "errorDuringPurchase": "Erreur durant l'achat", + "errorGameNotFound": "Partie non trouvée", "errorInvalidPhoneNumber": "Le numéro de téléphone est invalide", "errorLoadingPage": "Erreur rencontrée lors du chargement de la page", "errorPlayerAlreadyLinked": "Ce joueur est déjà associé à un autre compte", @@ -58,6 +61,11 @@ "gameAnticlockwiseDirection": "Ce jeu de carte se joue dans le sens antihorraire", "gameClockwiseDirection": "Ce jeu de carte se joue dans le sens horraire", "gameDistribution": "Distribution des parties", + "gameEnded": "Partie terminée", + "gameEndedNotificationMessage": "Une partie de {game_type} vient juste de se terminer ! Voulez-vous consulter les scores ?", + "@gameEndedNotificationMessage": {"placeholders":{"game_type": {}}}, + "gameEndedNotificationMessageSmall": "Une partie de {game_type} vient juste de se terminer !", + "@gameEndedNotificationMessageSmall": {"placeholders":{"game_type": {}}}, "gameIsStarting": "Partie en cours d'initialisation", "gameNotes": "Notes de partie", "games": "Parties", @@ -65,6 +73,7 @@ "idCopied": "ID copié dans le presse papier", "information": "Informations", "informationAboutTheApp": "Informations concernant l'application", + "informationMessage": "Message d'information", "inProgress": "En cours", "leave": "Quitter", "linkPlayer": "Lier un joueur existant à ce compte", @@ -83,6 +92,8 @@ "messagePlayerDistributeCardsFirsPart": "C'est au tours de", "messagePlayerDistributeCardsSecondPart": " de distribuer les cartes !", "messageSmsInfo": "Un SMS contenant un OTP va vous être envoyé. Des frais standards d'envoi de messages et d'échange de données s'appliquent", + "messageStartGame": "{count,plural, =1{Vous êtes sur le point d'envoyer une notification de demande de partie à ce joueur.euse :} other{Vous êtes sur le point d'envoyer une notification de demande de partie à ces joueurs.euses :}", + "@messageStartGame": {"placeholders":{"count": {}}}, "messageStopGame": "Tu es sur le point de terminer cette partie. Les gagnants ainsi que les perdants (honteux) vont être désignés", "messageWelcomeDescription": "L'application qui vous permet d'enregistrer vos parties de Belote, Coinche, Contrée et Tarot !", "myAccount": "Mon compte", @@ -90,11 +101,17 @@ "myProfile": "Mon profil", "myTeams": "Mes équipes", "newGame": "Nouvelle partie", + "newGameInvite": "Vous avez reçu une nouvelle invitation à une partie de {game_type} de la part de ce.tte joueur.euse : ", + "@newGameInvite": {"placeholders":{"game_type": {}}}, + "newGameInviteSmall": "Vous avez reçu une nouvelle invitation à une partie de {game_type} !", + "@newGameInviteSmall": {"placeholders":{"game_type": {}}}, + "newGameInviteJoinAsking": "Voulez-vous rejoindre cette partie ?", "newEmail": "Nouvelle adresse email", "newPhoneNumber": "Nouveau numéro de téléphone", "newPhoneNumberValidate": "Nouveau numéro de téléphone validé avec succès !", - "newPlayer": "Nouveau/nouvelle joueur/joueuse", + "newPlayer": "Nouveau.elle joueur.euse", "newRound": "Nouvelle manche", + "no": "Non", "noEmailProvided": "Pas d'email renseigné", "noGamesYet": "Pas encore de parties", "noName": "Sans nom", @@ -103,6 +120,7 @@ "noProducts": "Aucun produit n'est diponible à l'achat", "noScoreYet": "Pas encore de score", "noStatisticYet": "Pas encore de statistiques", + "notificationsTitle": "Notifications", "otp": "Mot de passe à usage unique (OTP)", "otpSend": "Code OTP envoyé avec succès", "or": "ou", @@ -120,8 +138,7 @@ "playerOrder": "Ordre des joueurs", "playerSelection": "Sélection des joueurs", "points": "{count,plural, =0{Point} other{{count} points}", - "@points": {"placeholders":{"count": {}}}, - + "@points": {"placeholders":{"count": {}}}, "privacyPolicy": "Politique de confidentialité", "profilePicture": "Image de profil (URL)", "profileTitle": "Profil", @@ -169,6 +186,7 @@ "them": "Eux", "trickPoints": "Point des plis", "type": "Type", + "yes": "Oui", "youDontHaveAnyPlayer": "Vous ne disposez pas de joueur", "unableAppInfo": "mpossible d'obtenir les informations de l'application", "unableToOpen": "Impossible d'ouvrir la page ", diff --git a/lib/main.dart b/lib/main.dart index 7fab398c..3ebef6b2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ import 'package:carg/app_theme.dart'; import 'package:carg/services/auth/auth_service.dart'; import 'package:carg/views/screens/home_screen.dart'; +import 'package:carg/views/screens/notifications_screen.dart'; import 'package:carg/views/screens/register/register_screen.dart'; import 'package:carg/views/screens/splash_screen.dart'; import 'package:carg/views/screens/user_screen.dart'; @@ -63,6 +64,7 @@ class _CargState extends State { routes: { UserScreen.routeName: (context) => const UserScreen(), RegisterScreen.routeName: (context) => RegisterScreen(), + NotificationsScreen.routeName: (context) => NotificationsScreen(), HomeScreen.routeName: (context) => HomeScreen( requestedIndex: ModalRoute.of(context)!.settings.arguments as int? ?? 0) diff --git a/lib/models/notification/abstract_game_notification.dart b/lib/models/notification/abstract_game_notification.dart new file mode 100644 index 00000000..607e8d6e --- /dev/null +++ b/lib/models/notification/abstract_game_notification.dart @@ -0,0 +1,61 @@ +import 'package:carg/helpers/correct_instance.dart'; +import 'package:carg/helpers/custom_route.dart'; +import 'package:carg/models/game/belote_game.dart'; +import 'package:carg/models/game/game_type.dart'; +import 'package:carg/models/game/tarot.dart'; +import 'package:carg/models/notification/abstract_notification.dart'; +import 'package:carg/models/players/belote_players.dart'; +import 'package:carg/services/game/abstract_game_service.dart'; +import 'package:carg/views/helpers/info_snackbar.dart'; +import 'package:carg/views/screens/play/play_belote_screen.dart'; +import 'package:carg/views/screens/play/play_tarot_game_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +abstract class AbstractGameNotification extends AbstractNotification { + final String gameId; + final GameType gameType; + + AbstractGameNotification( + {super.id, + super.notificationStatus, + super.timeStamp, + required super.kind, + required super.boundTo, + required this.gameId, + required this.gameType}); + + @override + Map toJSON() { + var tmpJSON = super.toJSON(); + tmpJSON.addAll({'game_id': gameId, 'game_type': gameType.name}); + return tmpJSON; + } + + /// Return the correct service to instanciate the associated game + AbstractGameService getGameService() { + return CorrectInstance.ofGameService(gameType); + } + + /// Open the game screen + void goToGame(BuildContext context) async { + var gameService = getGameService(); + var game = await gameService.get(gameId); + + if (game == null) { + InfoSnackBar.showErrorSnackBar( + context, AppLocalizations.of(context)!.errorGameNotFound); + } else { + Navigator.push( + context, + CustomRouteFade( + builder: (context) => gameType == GameType.TAROT + ? PlayTarotGameScreen(tarotGame: game as Tarot) + : PlayBeloteScreen( + beloteGame: game as Belote, + ), + ), + ); + } + } +} diff --git a/lib/models/notification/abstract_notification.dart b/lib/models/notification/abstract_notification.dart new file mode 100644 index 00000000..884c40d9 --- /dev/null +++ b/lib/models/notification/abstract_notification.dart @@ -0,0 +1,54 @@ +import 'package:carg/models/carg_object.dart'; +import 'package:enum_to_string/enum_to_string.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +abstract class AbstractNotification extends CargObject { + late DateTime timeStamp; + late NotificationStatus notificationStatus; + final String boundTo; + final NotificationKind kind; + + AbstractNotification( + {String? id, + NotificationStatus? notificationStatus, + DateTime? timeStamp, + required this.boundTo, + required this.kind}) + : super(id: id) { + this.timeStamp = timeStamp ?? DateTime.now(); + this.notificationStatus = notificationStatus ?? NotificationStatus.unread; + } + + @override + Map toJSON() { + return { + 'status': EnumToString.convertToString(notificationStatus), + 'time_stamp': DateFormat('yyyy-MM-ddTHH:mm:ss').format(timeStamp), + 'bound_to': boundTo, + 'kind': kind.name, + }; + } + + /// Return de localized message of de notification + String getMessage(BuildContext context); + + /// Return the Icon representing the notification kind + IconData getVisualIcon(); + + /// Return the dialog when the notification is pressed + Widget displayDialog(BuildContext context); + + /// Return the Icon representing the notification status + IconData getStatusIcon() { + if (notificationStatus == NotificationStatus.unread) { + return Icons.notifications_on_outlined; + } else { + return Icons.check_circle; + } + } +} + +enum NotificationStatus { read, unread } + +enum NotificationKind { newGameInvite, gameEnded, system } diff --git a/lib/models/notification/game_ended_notification.dart b/lib/models/notification/game_ended_notification.dart new file mode 100644 index 00000000..064b4d57 --- /dev/null +++ b/lib/models/notification/game_ended_notification.dart @@ -0,0 +1,92 @@ +import 'package:carg/models/game/game_type.dart'; +import 'package:carg/models/notification/abstract_game_notification.dart'; +import 'package:carg/models/notification/abstract_notification.dart'; +import 'package:carg/views/dialogs/warning_dialog.dart'; +import 'package:enum_to_string/enum_to_string.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; + +class GameEndedNotification extends AbstractGameNotification { + factory GameEndedNotification.fromJSON( + Map? json, String id) { + return GameEndedNotification( + id: id, + notificationStatus: + EnumToString.fromString(NotificationStatus.values, json?['status'])!, + timeStamp: DateTime.parse(json?['time_stamp']), + boundTo: json?['bound_to'], + gameId: json?['game_id'], + gameType: EnumToString.fromString(GameType.values, json?['game_type'])!, + ); + } + + GameEndedNotification( + {super.id, + super.notificationStatus, + super.timeStamp, + required super.boundTo, + required super.gameId, + required super.gameType}) + : super(kind: NotificationKind.gameEnded); + + @override + Map toJSON() { + var tmpJSON = super.toJSON(); + tmpJSON.addAll({'game_id': gameId, 'game_type': gameType.name}); + return tmpJSON; + } + + @override + String getMessage(BuildContext context) { + return AppLocalizations.of(context)! + .gameEndedNotificationMessageSmall(gameType.name); + } + + @override + IconData getVisualIcon() { + return FontAwesomeIcons.gamepad; + } + + @override + Widget displayDialog(BuildContext context) { + return WarningDialog( + onConfirmButtonMessage: AppLocalizations.of(context)!.yes, + onCancelButtonMessage: AppLocalizations.of(context)!.no, + onConfirm: () async => {goToGame(context)}, + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + getVisualIcon(), + size: 40, + ), + const SizedBox( + width: 20, + ), + Text( + gameType.name, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox( + height: 10, + ), + Text( + AppLocalizations.of(context)!.gameEndedNotificationMessage( + gameType.name, + ), + ), + ], + ), + title: AppLocalizations.of(context)!.gameEnded, + color: Theme.of(context).primaryColor, + ); + } +} diff --git a/lib/models/notification/new_game_notification.dart b/lib/models/notification/new_game_notification.dart new file mode 100644 index 00000000..c90f65ba --- /dev/null +++ b/lib/models/notification/new_game_notification.dart @@ -0,0 +1,110 @@ +import 'package:carg/models/game/game_type.dart'; +import 'package:carg/models/notification/abstract_game_notification.dart'; +import 'package:carg/models/notification/abstract_notification.dart'; +import 'package:carg/views/dialogs/warning_dialog.dart'; +import 'package:carg/views/widgets/api_mini_player_widget.dart'; +import 'package:enum_to_string/enum_to_string.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; + +class NewGameNotification extends AbstractGameNotification { + final String sourcePlayerId; + + factory NewGameNotification.fromJSON(Map? json, String id) { + return NewGameNotification( + id: id, + notificationStatus: + EnumToString.fromString(NotificationStatus.values, json?['status'])!, + timeStamp: DateTime.parse(json?['time_stamp']), + boundTo: json?['bound_to'], + gameId: json?['game_id'], + sourcePlayerId: json?['source_player_id'], + gameType: EnumToString.fromString(GameType.values, json?['game_type'])!, + ); + } + + NewGameNotification( + {super.id, + super.notificationStatus, + super.timeStamp, + required super.boundTo, + required super.gameId, + required this.sourcePlayerId, + required super.gameType}) + : super(kind: NotificationKind.newGameInvite); + + @override + Map toJSON() { + var tmpJSON = super.toJSON(); + tmpJSON.addAll({ + 'game_id': gameId, + 'source_player_id': sourcePlayerId, + 'kind': NotificationKind.newGameInvite.name, + 'game_type': gameType.name + }); + return tmpJSON; + } + + @override + String getMessage(BuildContext context) { + return AppLocalizations.of(context)!.newGameInviteSmall(gameType.name); + } + + @override + IconData getVisualIcon() { + return FontAwesomeIcons.gamepad; + } + + @override + Widget displayDialog(BuildContext context) { + return WarningDialog( + onConfirmButtonMessage: AppLocalizations.of(context)!.yes, + onCancelButtonMessage: AppLocalizations.of(context)!.no, + onConfirm: () async => {goToGame(context)}, + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + getVisualIcon(), + size: 40, + ), + const SizedBox( + width: 20, + ), + Text( + gameType.name, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox( + height: 10, + ), + Text( + AppLocalizations.of(context)!.newGameInvite( + gameType.name, + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: APIMiniPlayerWidget( + playerId: sourcePlayerId, + displayImage: true, + ), + ), + Text( + AppLocalizations.of(context)!.newGameInviteJoinAsking, + ), + ], + ), + title: AppLocalizations.of(context)!.newGame, + color: Theme.of(context).primaryColor, + ); + } +} diff --git a/lib/models/notification/system_notification.dart b/lib/models/notification/system_notification.dart new file mode 100644 index 00000000..ddea3d43 --- /dev/null +++ b/lib/models/notification/system_notification.dart @@ -0,0 +1,76 @@ +import 'package:carg/models/notification/abstract_notification.dart'; +import 'package:carg/views/dialogs/warning_dialog.dart'; +import 'package:enum_to_string/enum_to_string.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class SystemNotification extends AbstractNotification { + final String message; + + factory SystemNotification.fromJSON(Map? json, String id) { + return SystemNotification( + id: id, + notificationStatus: + EnumToString.fromString(NotificationStatus.values, json?['status'])!, + timeStamp: DateTime.parse(json?['time_stamp']), + boundTo: json?['bound_to'], + message: json?['message'], + ); + } + + SystemNotification( + {super.id, + super.notificationStatus, + super.timeStamp, + required super.boundTo, + required this.message}) + : super(kind: NotificationKind.system); + + @override + Map toJSON() { + var tmpJSON = super.toJSON(); + tmpJSON.addAll({'message': message}); + return tmpJSON; + } + + @override + String getMessage(BuildContext context) { + return AppLocalizations.of(context)!.information; + } + + @override + IconData getVisualIcon() { + return Icons.info_outline_rounded; + } + + @override + Widget displayDialog(BuildContext context) { + return WarningDialog( + showCancelButton: false, + onConfirm: () async => {}, + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Icon( + getVisualIcon(), + size: 40, + ), + ), + Flexible( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + style: const TextStyle(fontSize: 20), + message, + ), + ), + ), + ], + ), + title: AppLocalizations.of(context)!.information, + color: Theme.of(context).colorScheme.secondary, + ); + } +} diff --git a/lib/repositories/impl/notification_repository.dart b/lib/repositories/impl/notification_repository.dart new file mode 100644 index 00000000..c785fdce --- /dev/null +++ b/lib/repositories/impl/notification_repository.dart @@ -0,0 +1,89 @@ +import 'dart:async'; + +import 'package:carg/const.dart'; +import 'package:carg/exceptions/repository_exception.dart'; +import 'package:carg/models/notification/abstract_notification.dart'; +import 'package:carg/models/notification/game_ended_notification.dart'; +import 'package:carg/models/notification/new_game_notification.dart'; +import 'package:carg/models/notification/system_notification.dart'; +import 'package:carg/repositories/notification/abstract_notification_repository.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; + +class NotificationRepository extends AbstractNotificationRepository { + NotificationRepository( + {String? database, + String? environment, + FirebaseFirestore? provider, + DocumentSnapshot? lastFetchGameDocument}) + : super( + database: database ?? Const.notificationDB, + environment: environment ?? + const String.fromEnvironment(Const.dartVarEnv, + defaultValue: Const.defaultEnv), + provider: provider ?? FirebaseFirestore.instance, + lastFetchGameDocument: lastFetchGameDocument); + + @override + Future get(String id) { + throw UnimplementedError(); + } + + @override + Future getNotificationOfUser(String userId, + StreamController> streamController) { + try { + return provider + .collection(connectionString) + .where('bound_to', isEqualTo: userId) + .snapshots() + .map((element) { + List notifications = []; + for (var doc in element.docs) { + final Map value = doc.data(); + var kind = value['kind']; + if (kind == NotificationKind.system.name) { + notifications.add(SystemNotification.fromJSON(value, doc.id)); + } else if (kind == NotificationKind.newGameInvite.name) { + notifications.add(NewGameNotification.fromJSON(value, doc.id)); + } else if (kind == NotificationKind.gameEnded.name) { + notifications.add(GameEndedNotification.fromJSON(value, doc.id)); + } + } + return notifications; + }).pipe(streamController); + } on FirebaseException catch (e) { + throw RepositoryException(e.message!); + } + } + + @override + Future getNotificationOfStatusOfUser( + String userId, + StreamController> streamController, + NotificationStatus status) { + try { + return provider + .collection(connectionString) + .where('bound_to', isEqualTo: userId) + .where('status', isEqualTo: status.name) + .snapshots() + .map((element) { + List notifications = []; + for (var doc in element.docs) { + final Map value = doc.data(); + var kind = value['kind']; + if (kind == 'system') { + notifications.add(SystemNotification.fromJSON(value, doc.id)); + } else if (kind == 'newGameInvite') { + notifications.add(NewGameNotification.fromJSON(value, doc.id)); + } else if (kind == 'gameEnded') { + notifications.add(GameEndedNotification.fromJSON(value, doc.id)); + } + } + return notifications; + }).pipe(streamController); + } on FirebaseException catch (e) { + throw RepositoryException(e.message!); + } + } +} diff --git a/lib/repositories/notification/abstract_notification_repository.dart b/lib/repositories/notification/abstract_notification_repository.dart new file mode 100644 index 00000000..6345ca1a --- /dev/null +++ b/lib/repositories/notification/abstract_notification_repository.dart @@ -0,0 +1,31 @@ +import 'dart:async'; + +import 'package:carg/models/notification/abstract_notification.dart'; +import 'package:carg/repositories/base_repository.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; + +abstract class AbstractNotificationRepository + extends BaseRepository { + AbstractNotificationRepository( + {required String database, + required String environment, + required FirebaseFirestore provider, + DocumentSnapshot? lastFetchGameDocument}) + : super( + database: database, + environment: environment, + provider: provider, + lastFetchGameDocument: lastFetchGameDocument); + + /// Get the notifications of a particular user via his/her/them [userId] + /// Return the notifications or null if not found + Future getNotificationOfUser(String userId, + StreamController> streamController); + + /// Get the notifications of a particular status user via his/her/them [userId] + /// Return the notifications or null if not found + Future getNotificationOfStatusOfUser( + String userId, + StreamController> streamController, + NotificationStatus status); +} diff --git a/lib/services/game/abstract_game_service.dart b/lib/services/game/abstract_game_service.dart index bb93125f..1316d326 100644 --- a/lib/services/game/abstract_game_service.dart +++ b/lib/services/game/abstract_game_service.dart @@ -61,7 +61,7 @@ abstract class AbstractGameService /// Create a new game with a player list /// [playerListForOrder] is used to keep the play order of the players - /// [playerListForTeam] is used to create the teams od the game + /// [playerListForTeam] is used to create the teams of the game /// Return the new game Future createGameWithPlayerList(List playerListForOrder, List playerListForTeam, DateTime? startingDate); diff --git a/lib/services/impl/notification_service.dart b/lib/services/impl/notification_service.dart new file mode 100644 index 00000000..4df5c974 --- /dev/null +++ b/lib/services/impl/notification_service.dart @@ -0,0 +1,54 @@ +import 'dart:async'; + +import 'package:carg/exceptions/repository_exception.dart'; +import 'package:carg/exceptions/service_exception.dart'; +import 'package:carg/models/notification/abstract_notification.dart'; +import 'package:carg/repositories/impl/notification_repository.dart'; +import 'package:carg/repositories/notification/abstract_notification_repository.dart'; +import 'package:carg/services/notification/abstract_notification_service.dart'; + +class NotificationService extends AbstractNotificationService { + NotificationService({AbstractNotificationRepository? notificationRepository}) + : super( + notificationRepository: + notificationRepository ?? NotificationRepository()); + + @override + Future getAllNotificationsOfUser( + String? userId, + StreamController> streamController, + NotificationStatus? status) { + if (userId == null) { + throw ServiceException('Please use a non null user id'); + } + try { + Future notificationStream; + if (status == null) { + notificationStream = notificationRepository.getNotificationOfUser( + userId, streamController); + } else { + notificationStream = + notificationRepository.getNotificationOfStatusOfUser( + userId, streamController, NotificationStatus.unread); + } + return notificationStream; + } on RepositoryException catch (e) { + throw ServiceException( + 'Error on streaming the score by user ID [$userId] : ${e.message}'); + } + } + + @override + Future markNotificationAsRead(String? notificationId) async { + if (notificationId == null) { + throw ServiceException('Please use a non notification id'); + } + try { + await notificationRepository.updateField( + notificationId, 'status', NotificationStatus.read.name); + } on RepositoryException catch (e) { + throw ServiceException( + 'Error marking as read the notification [$notificationId] : ${e.message}'); + } + } +} diff --git a/lib/services/notification/abstract_notification_service.dart b/lib/services/notification/abstract_notification_service.dart new file mode 100644 index 00000000..2e32c409 --- /dev/null +++ b/lib/services/notification/abstract_notification_service.dart @@ -0,0 +1,24 @@ +import 'dart:async'; + +import 'package:carg/models/notification/abstract_notification.dart'; +import 'package:carg/repositories/notification/abstract_notification_repository.dart'; +import 'package:carg/services/base_abstract_service.dart'; + +abstract class AbstractNotificationService + extends BaseAbstractService { + final AbstractNotificationRepository notificationRepository; + + AbstractNotificationService({required this.notificationRepository}) + : super(repository: notificationRepository); + + /// Get the notifications of a particular user via his/her/them [userId] + /// Return the notification or null if not found + Future getAllNotificationsOfUser( + String? userId, + StreamController> streamController, + NotificationStatus status); + + /// Mark a notification [notificationId] as read + /// Return the notification or null if not found + Future markNotificationAsRead(String? notificationId); +} diff --git a/lib/views/dialogs/warning_dialog.dart b/lib/views/dialogs/warning_dialog.dart index 4490299d..2a69f945 100644 --- a/lib/views/dialogs/warning_dialog.dart +++ b/lib/views/dialogs/warning_dialog.dart @@ -3,22 +3,27 @@ import 'package:carg/styles/text_style.dart'; import 'package:flutter/material.dart'; class WarningDialog extends StatefulWidget { - final String message; final String title; final Function onConfirm; final Color? color; final bool showCancelButton; final String? onConfirmButtonMessage; + final String? onCancelButtonMessage; + final String? message; + final Widget? content; const WarningDialog( {Key? key, - required this.message, + this.message, + this.content, required this.title, required this.onConfirm, this.color, this.showCancelButton = true, - this.onConfirmButtonMessage}) - : super(key: key); + this.onConfirmButtonMessage, + this.onCancelButtonMessage}) + : assert((message != null) ^ (content != null)), + super(key: key); @override State createState() { @@ -56,10 +61,14 @@ class _WarningDialogState extends State { actionsPadding: const EdgeInsets.fromLTRB(0, 0, 20, 10), title: Container( decoration: BoxDecoration( - color: widget.color ?? Theme.of(context).errorColor, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(15.0), - topRight: Radius.circular(15.0))), + color: widget.color ?? Theme.of(context).errorColor, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(15.0), + topRight: Radius.circular( + 15.0, + ), + ), + ), padding: const EdgeInsets.fromLTRB(20, 20, 0, 15), child: Text( widget.title, @@ -67,25 +76,35 @@ class _WarningDialogState extends State { style: CustomTextStyle.dialogHeaderStyle(context), ), ), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)), - content: Text( - widget.message, - style: const TextStyle(fontSize: 22), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + 15, + ), ), + content: widget.message != null + ? Text( + widget.message!, + style: const TextStyle(fontSize: 22), + ) + : widget.content!, actions: [ if (_isLoading) const CircularProgressIndicator() else ElevatedButton.icon( style: ButtonStyle( - backgroundColor: MaterialStateProperty.all( - widget.color ?? Theme.of(context).errorColor), - foregroundColor: MaterialStateProperty.all( - Theme.of(context).cardColor), - shape: MaterialStateProperty.all( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - CustomProperties.borderRadius)))), + backgroundColor: MaterialStateProperty.all( + widget.color ?? Theme.of(context).errorColor), + foregroundColor: MaterialStateProperty.all( + Theme.of(context).cardColor), + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + CustomProperties.borderRadius, + ), + ), + ), + ), onPressed: () async => {await _exec(), Navigator.pop(context)}, label: Text( widget.onConfirmButtonMessage?.toUpperCase() ?? @@ -95,18 +114,23 @@ class _WarningDialogState extends State { if (widget.showCancelButton) ElevatedButton.icon( style: ButtonStyle( - backgroundColor: MaterialStateProperty.all( - Theme.of(context).cardColor), - foregroundColor: MaterialStateProperty.all( - widget.color ?? Theme.of(context).errorColor), - shape: MaterialStateProperty.all( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - CustomProperties.borderRadius)))), + backgroundColor: + MaterialStateProperty.all(Theme.of(context).cardColor), + foregroundColor: MaterialStateProperty.all( + widget.color ?? Theme.of(context).errorColor), + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + CustomProperties.borderRadius, + ), + ), + ), + ), onPressed: () => {Navigator.pop(context)}, icon: const Icon(Icons.close), label: Text( - MaterialLocalizations.of(context).cancelButtonLabel, + widget.onCancelButtonMessage?.toUpperCase() ?? + MaterialLocalizations.of(context).cancelButtonLabel, ), ) else diff --git a/lib/views/screens/game_mode_picker_screen.dart b/lib/views/screens/game_mode_picker_screen.dart index 44c924b1..648785d7 100644 --- a/lib/views/screens/game_mode_picker_screen.dart +++ b/lib/views/screens/game_mode_picker_screen.dart @@ -109,7 +109,7 @@ class _GameModeButton extends StatelessWidget { Navigator.of(context).push( CustomRouteLeftToRight( builder: (context) => - PlayerPickerScreen(game: game, title: game!.gameType.name), + PlayerPickerScreen(game: game), ), ) }, diff --git a/lib/views/screens/notifications_screen.dart b/lib/views/screens/notifications_screen.dart new file mode 100644 index 00000000..63960808 --- /dev/null +++ b/lib/views/screens/notifications_screen.dart @@ -0,0 +1,121 @@ +import 'dart:async'; + +import 'package:carg/models/notification/abstract_notification.dart' as no; +import 'package:carg/services/impl/notification_service.dart'; +import 'package:carg/styles/text_style.dart'; +import 'package:carg/views/widgets/error_message_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:intl/intl.dart'; + +class NotificationsScreen extends StatefulWidget { + static const routeName = '/notifications'; + final NotificationService notificationService = NotificationService(); + + NotificationsScreen({Key? key}) : super(key: key); + + @override + State createState() { + return _NotificationsScreenState(); + } +} + +class _NotificationsScreenState extends State + with SingleTickerProviderStateMixin { + List list = []; + late StreamController> streamController; + + @override + void initState() { + streamController = StreamController.broadcast(); + widget.notificationService + .getAllNotificationsOfUser('id', streamController, null); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + automaticallyImplyLeading: false, + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + title: Text( + AppLocalizations.of(context)!.notificationsTitle, + style: CustomTextStyle.screenHeadLine1( + context, + ), + ), + ), + body: StreamBuilder>( + stream: streamController.stream, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center( + child: CircularProgressIndicator(), + ); + } + if (snapshot.hasError) { + return ErrorMessageWidget(message: snapshot.error.toString()); + } + if (snapshot.hasData) { + return ListView.builder( + itemCount: snapshot.data!.length, + itemBuilder: (BuildContext context, int index) { + return _NotificationListTile( + notification: snapshot.data![index], + notificationService: widget.notificationService, + ); + }, + ); + } else { + return Container(); + } + }, + ), + ); + } +} + +class _NotificationListTile extends StatelessWidget { + final no.AbstractNotification notification; + final NotificationService notificationService; + + const _NotificationListTile( + {Key? key, required this.notification, required this.notificationService}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () async => { + notificationService.markNotificationAsRead(notification.id), + await showDialog( + context: context, + builder: (BuildContext context) => + notification.displayDialog(context)) + }, + child: ListTile( + leading: Icon( + notification.getStatusIcon(), + size: 30, + color: Theme.of(context).primaryColor, + ), + title: Text( + notification.getMessage(context), + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + subtitle: Text( + DateFormat.yMMMEd(Localizations.localeOf(context).languageCode) + .format( + notification.timeStamp, + ), + ), + ), + ); + } +} diff --git a/lib/views/screens/play/play_belote_screen.dart b/lib/views/screens/play/play_belote_screen.dart index c4e0a2d9..0ce75655 100644 --- a/lib/views/screens/play/play_belote_screen.dart +++ b/lib/views/screens/play/play_belote_screen.dart @@ -24,17 +24,24 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart'; class PlayBeloteScreen extends StatefulWidget { final Belote beloteGame; - final AbstractGameService gameService; - final AbstractScoreService scoreService; - final AbstractRoundService roundService; + late final AbstractGameService gameService; + late final AbstractScoreService scoreService; + late final AbstractRoundService roundService; - const PlayBeloteScreen( + PlayBeloteScreen( {Key? key, required this.beloteGame, - required this.gameService, - required this.scoreService, - required this.roundService}) - : super(key: key); + AbstractGameService? gameService, + AbstractScoreService? scoreService, + AbstractRoundService? roundService}) + : super(key: key) { + this.gameService = + gameService ?? CorrectInstance.ofGameService(beloteGame.gameType); + this.scoreService = + scoreService ?? CorrectInstance.ofScoreService(beloteGame.gameType); + this.roundService = + roundService ?? CorrectInstance.ofRoundService(beloteGame.gameType); + } @override State createState() { @@ -52,7 +59,8 @@ class _PlayBeloteScreenState extends State { builder: (context) => AddBeloteRoundScreen( beloteGame: widget.beloteGame, beloteRound: widget.roundService.getNewRound() as BeloteRound?, - roundService: CorrectInstance.ofRoundService(widget.beloteGame), + roundService: + CorrectInstance.ofRoundService(widget.beloteGame.gameType), ), ), ); @@ -101,7 +109,8 @@ class _PlayBeloteScreenState extends State { builder: (context) => AddBeloteRoundScreen( beloteGame: widget.beloteGame, beloteRound: lastRound as BeloteRound, - roundService: CorrectInstance.ofRoundService(widget.beloteGame), + roundService: + CorrectInstance.ofRoundService(widget.beloteGame.gameType), isEditing: true), ), ); diff --git a/lib/views/screens/play/play_tarot_game_screen.dart b/lib/views/screens/play/play_tarot_game_screen.dart index 0b5857c5..b24d286f 100644 --- a/lib/views/screens/play/play_tarot_game_screen.dart +++ b/lib/views/screens/play/play_tarot_game_screen.dart @@ -121,7 +121,8 @@ class _PlayTarotGameScreenState extends State { context: context, builder: (BuildContext context) => NotesDialog( game: widget.tarotGame, - gameService: CorrectInstance.ofGameService(widget.tarotGame))); + gameService: + CorrectInstance.ofGameService(widget.tarotGame.gameType))); } @override diff --git a/lib/views/screens/player_order_screen.dart b/lib/views/screens/player_order_screen.dart index 58e07364..90793210 100644 --- a/lib/views/screens/player_order_screen.dart +++ b/lib/views/screens/player_order_screen.dart @@ -1,4 +1,3 @@ -import 'package:carg/helpers/correct_instance.dart'; import 'package:carg/helpers/custom_route.dart'; import 'package:carg/models/game/belote_game.dart'; import 'package:carg/models/game/game.dart'; @@ -6,27 +5,31 @@ import 'package:carg/models/game/game_type.dart'; import 'package:carg/models/game/tarot.dart'; import 'package:carg/models/player.dart'; import 'package:carg/models/players/belote_players.dart'; +import 'package:carg/models/players/players.dart'; +import 'package:carg/services/auth/auth_service.dart'; import 'package:carg/services/game/abstract_game_service.dart'; import 'package:carg/styles/properties.dart'; import 'package:carg/styles/text_style.dart'; import 'package:carg/views/dialogs/dialogs.dart'; +import 'package:carg/views/dialogs/warning_dialog.dart'; +import 'package:carg/views/helpers/info_snackbar.dart'; import 'package:carg/views/screens/play/play_belote_screen.dart'; import 'package:carg/views/screens/play/play_tarot_game_screen.dart'; +import 'package:carg/views/screens/register/waiting_room_screen.dart'; import 'package:carg/views/widgets/players/draggable_player_widget.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:provider/provider.dart'; class PlayerOrderScreen extends StatefulWidget { final Game game; final List playerList; - final String title; final AbstractGameService gameService; const PlayerOrderScreen( {Key? key, required this.playerList, required this.game, - required this.title, required this.gameService}) : super(key: key); @@ -40,19 +43,28 @@ class _PlayerOrderScreenState extends State { final GlobalKey _keyLoader = GlobalKey(); late final List playerListForTeam = List.from(widget.playerList); late final List playerListForOrder = List.from(widget.playerList); - Game? _newGame; - Future _createGame() async { - Dialogs.showLoadingDialog( - context, _keyLoader, AppLocalizations.of(context)!.gameIsStarting); - var gameTmp = (await widget.gameService.createGameWithPlayerList( - playerListForOrder.map((e) => e.id).toList(), - playerListForTeam.map((e) => e.id).toList(), - DateTime.now())); - setState(() { - _newGame = gameTmp; - }); - Navigator.of(_keyLoader.currentContext!, rootNavigator: true).pop(); + List _getRealPlayers() { + var myId = + Provider.of(context, listen: false).getPlayer()!.id!; + return widget.playerList + .expand((e) => [if (!e.owned && e.id != myId) e]) + .toList(); + } + + String _getRealPlayersUsernames(List players) { + var message = ''; + for (var i = 0; i < players.length; i++) { + if (i == 0) { + message += players[i].userName; + } else if (i == players.length - 1) { + message += + " ${AppLocalizations.of(context)!.and} ${players[i].userName}"; + } else { + message += ", ${players[i].userName}"; + } + } + return message; } void _onReorder(int oldIndex, int newIndex) { @@ -66,14 +78,77 @@ class _PlayerOrderScreenState extends State { }); } + Future _createGame(BuildContext localContext) async { + var realPlayers = _getRealPlayers(); + var newGame = await widget.gameService.createGameWithPlayerList( + playerListForOrder.map((e) => e.id).toList(), + playerListForTeam.map((e) => e.id).toList(), + DateTime.now()); + + /// If other real players, show the dialog + if (realPlayers.isNotEmpty) { + var sendNotification = await displayDialog(realPlayers, newGame); + if (sendNotification == null || sendNotification != true) { + return null; + } else { + return newGame; + } + } else { + return newGame; + } + } + + Future displayDialog(List realPlayerList, Game newGame) async { + return showDialog( + context: context, + builder: (BuildContext context) => WarningDialog( + onConfirmButtonMessage: AppLocalizations.of(context)!.yes, + onCancelButtonMessage: AppLocalizations.of(context)!.no, + onConfirm: () => {true}, + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + AppLocalizations.of(context)! + .messageStartGame(realPlayerList.length), + style: const TextStyle( + fontSize: 23, + ), + ), + Padding( + padding: const EdgeInsets.all(20.0), + child: Text( + _getRealPlayersUsernames(realPlayerList), + style: + const TextStyle(fontWeight: FontWeight.bold, fontSize: 23), + ), + ), + Text( + AppLocalizations.of(context)!.doYouWantToContinue, + style: const TextStyle( + fontSize: 23, + ), + ) + ], + ), + title: AppLocalizations.of(context)!.information, + color: Theme.of(context).colorScheme.secondary, + ), + ); + } + @override Widget build(BuildContext context) { return Scaffold( appBar: PreferredSize( preferredSize: const Size.fromHeight(60), child: AppBar( - title: Text(widget.title, - style: CustomTextStyle.screenHeadLine1(context)), + title: Text( + widget.game.gameType.name, + style: CustomTextStyle.screenHeadLine1( + context, + ), + ), ), ), body: Padding( @@ -82,24 +157,35 @@ class _PlayerOrderScreenState extends State { children: [ Padding( padding: const EdgeInsets.all(8.0), - child: Text(AppLocalizations.of(context)!.orderOfPlay, - style: const TextStyle( - fontSize: 20, fontWeight: FontWeight.bold)), + child: Text( + AppLocalizations.of(context)!.orderOfPlay, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), ), Flexible( - child: ReorderableListView( - onReorder: _onReorder, - children: playerListForOrder - .asMap() - .map((i, player) => MapEntry( - i, - Container( - key: ValueKey(player), - child: DraggablePlayerWidget( - player: player, index: i), - ))) - .values - .toList())), + child: ReorderableListView( + onReorder: _onReorder, + children: playerListForOrder + .asMap() + .map( + (i, player) => MapEntry( + i, + DraggablePlayerWidget( + player: player, + index: i, + key: ValueKey( + player, + ), + ), + ), + ) + .values + .toList(), + ), + ), Padding( padding: const EdgeInsets.all(8.0), child: Text( @@ -128,25 +214,23 @@ class _PlayerOrderScreenState extends State { ), ), ), - onPressed: () async => { - await _createGame(), - Navigator.pushAndRemoveUntil( + onPressed: () async { + var newGame = await _createGame(context); + if (newGame != null) { + Navigator.pushAndRemoveUntil( context, CustomRouteFade( - builder: (context) => _newGame!.gameType != + builder: (context) => newGame.gameType != GameType.TAROT ? PlayBeloteScreen( - gameService: widget.gameService, - scoreService: - CorrectInstance.ofScoreService(_newGame!), - roundService: - CorrectInstance.ofRoundService(_newGame!), - beloteGame: _newGame as Belote) + beloteGame: newGame as Belote) : PlayTarotGameScreen( - tarotGame: _newGame as Tarot, + tarotGame: newGame as Tarot, ), ), - ModalRoute.withName('/')) + ModalRoute.withName('/'), + ); + } }, label: Text( AppLocalizations.of(context)!.startTheGame, diff --git a/lib/views/screens/player_picker_screen.dart b/lib/views/screens/player_picker_screen.dart index 495ea139..7edbe38b 100644 --- a/lib/views/screens/player_picker_screen.dart +++ b/lib/views/screens/player_picker_screen.dart @@ -17,9 +17,8 @@ import 'package:provider/provider.dart'; class PlayerPickerScreen extends StatefulWidget { final Game? game; - final String? title; - const PlayerPickerScreen({Key? key, required this.game, required this.title}) + const PlayerPickerScreen({Key? key, required this.game}) : super(key: key); @override @@ -58,7 +57,7 @@ class _PlayerPickerScreenState extends State { appBar: PreferredSize( preferredSize: const Size.fromHeight(60), child: AppBar( - title: Text(widget.title!, + title: Text(widget.game!.gameType.name, style: CustomTextStyle.screenHeadLine1(context)), ), ), @@ -160,10 +159,9 @@ class _PlayerPickerScreenState extends State { CustomRouteLeftToRight( builder: (context) => PlayerOrderScreen( playerList: newPlayers!, - title: widget.title!, game: widget.game!, gameService: CorrectInstance.ofGameService( - widget.game!, + widget.game!.gameType, ), ), ), diff --git a/lib/views/screens/register/waiting_room_screen.dart b/lib/views/screens/register/waiting_room_screen.dart new file mode 100644 index 00000000..b23c37a8 --- /dev/null +++ b/lib/views/screens/register/waiting_room_screen.dart @@ -0,0 +1,22 @@ +import 'package:carg/models/game/game.dart'; +import 'package:carg/models/player.dart'; +import 'package:flutter/material.dart'; + +class WaitingRoomScreen extends StatefulWidget { + final Game game; + final List playerList; + + const WaitingRoomScreen( + {Key? key, required this.game, required this.playerList}) + : super(key: key); + + @override + State createState() => _WaitingRoomScreenState(); +} + +class _WaitingRoomScreenState extends State { + @override + Widget build(BuildContext context) { + return const Placeholder(); + } +} diff --git a/lib/views/screens/user_screen.dart b/lib/views/screens/user_screen.dart index 8e248970..0e92dd99 100644 --- a/lib/views/screens/user_screen.dart +++ b/lib/views/screens/user_screen.dart @@ -7,6 +7,7 @@ import 'package:carg/services/impl/player_service.dart'; import 'package:carg/styles/properties.dart'; import 'package:carg/styles/text_style.dart'; import 'package:carg/views/screens/settings_screen.dart'; +import 'package:carg/views/widgets/bell_notification_widget.dart'; import 'package:carg/views/widgets/error_message_widget.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -413,27 +414,28 @@ class _AppBarTitle extends StatelessWidget { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(AppLocalizations.of(context)!.profileTitle, - style: CustomTextStyle.screenHeadLine1(context)), - ElevatedButton.icon( - style: ButtonStyle( - backgroundColor: - MaterialStateProperty.all(Theme.of(context).cardColor), - foregroundColor: MaterialStateProperty.all( - Theme.of(context).primaryColor), - shape: MaterialStateProperty.all( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - CustomProperties.borderRadius, + Text( + AppLocalizations.of(context)!.profileTitle, + style: CustomTextStyle.screenHeadLine1(context), + ), + Flexible( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const BellNotificationWidget(), + RawMaterialButton( + constraints: const BoxConstraints(minHeight: 40, minWidth: 60), + onPressed: () async => await onPressEdit(), + elevation: 2.0, + fillColor: Theme.of(context).cardColor, + textStyle: TextStyle(color: Theme.of(context).primaryColor), + shape: const CircleBorder(), + child: const Icon( + FontAwesomeIcons.gear, + size: 18, ), ), - ), - ), - onPressed: () async => await onPressEdit(), - label: Text(AppLocalizations.of(context)!.settings), - icon: const Icon( - FontAwesomeIcons.gears, - size: 13, + ], ), ), ], @@ -448,39 +450,73 @@ class _WonPlayedWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return Row(mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - Flexible( - child: Column(children: [ - const Icon(FontAwesomeIcons.trophy, size: 20), - Padding( - padding: const EdgeInsets.all(5.0), - child: Text(player!.totalWonGames().toString(), + return Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Flexible( + child: Column( + children: [ + const Icon(FontAwesomeIcons.trophy, size: 20), + Padding( + padding: const EdgeInsets.all(5.0), + child: Text( + player!.totalWonGames().toString(), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + Text( + AppLocalizations.of(context)!.victories, style: const TextStyle( - fontSize: 20, fontWeight: FontWeight.bold))), - Text(AppLocalizations.of(context)!.victories, - style: const TextStyle(fontSize: 20)) - ])), - Container( - decoration: BoxDecoration( + fontSize: 20, + ), + ), + ], + ), + ), + Container( + decoration: BoxDecoration( border: Border( - bottom: BorderSide( - color: Theme.of(context).primaryColor, - width: 6, - ))), - child: Text('${player!.totalWinPercentage()} %', - style: const TextStyle(fontSize: 35, fontWeight: FontWeight.bold)), - ), - Flexible( - child: Column(children: [ - const Icon(FontAwesomeIcons.gamepad, size: 20), - Padding( - padding: const EdgeInsets.all(5.0), - child: Text(player!.totalPlayedGames().toString(), + bottom: BorderSide( + color: Theme.of(context).primaryColor, + width: 6, + ), + ), + ), + child: Text( + '${player!.totalWinPercentage()} %', + style: const TextStyle( + fontSize: 35, + fontWeight: FontWeight.bold, + ), + ), + ), + Flexible( + child: Column( + children: [ + const Icon(FontAwesomeIcons.gamepad, size: 20), + Padding( + padding: const EdgeInsets.all(5.0), + child: Text( + player!.totalPlayedGames().toString(), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + Text( + AppLocalizations.of(context)!.games, style: const TextStyle( - fontSize: 20, fontWeight: FontWeight.bold))), - Text(AppLocalizations.of(context)!.games, - style: const TextStyle(fontSize: 20)) - ])) - ]); + fontSize: 20, + ), + ), + ], + ), + ), + ], + ); } } diff --git a/lib/views/widgets/api_mini_player_widget.dart b/lib/views/widgets/api_mini_player_widget.dart index 904c0928..1b47c360 100644 --- a/lib/views/widgets/api_mini_player_widget.dart +++ b/lib/views/widgets/api_mini_player_widget.dart @@ -1,4 +1,5 @@ import 'package:carg/models/player.dart'; +import 'package:carg/services/impl/player_service.dart'; import 'package:carg/services/player/abstract_player_service.dart'; import 'package:carg/views/dialogs/player_info_dialog.dart'; import 'package:flutter/material.dart'; @@ -14,20 +15,22 @@ class APIMiniPlayerWidget extends StatelessWidget { final Function? onTap; final Color? selectedColor; final String additionalText; - final AbstractPlayerService playerService; + late AbstractPlayerService playerService; - const APIMiniPlayerWidget( + APIMiniPlayerWidget( {Key? key, required this.playerId, required this.displayImage, - required this.playerService, + AbstractPlayerService? playerService, this.showLoading = true, this.size = 15, this.isSelected = false, this.onTap, this.selectedColor, this.additionalText = ''}) - : super(key: key); + : super(key: key) { + this.playerService = playerService ?? PlayerService(); + } Future _showEditPlayerDialog(BuildContext context, Player? player) async { if (player != null) { @@ -61,46 +64,56 @@ class APIMiniPlayerWidget extends StatelessWidget { } if (snapshot.hasData) { child = Material( + color: Theme.of(context).cardColor, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: InputChip( - selected: isSelected, - selectedColor: selectedColor ?? - Theme.of(context).colorScheme.secondary, - onPressed: onTap as void Function()? ?? - () => {_showEditPlayerDialog(context, snapshot.data)}, - avatar: - (snapshot.data!.profilePicture != '' && displayImage) - ? Container( - decoration: BoxDecoration( - border: Border.all( - width: 2, - color: Theme.of(context).primaryColor, - ), - shape: BoxShape.circle, - image: DecorationImage( - image: NetworkImage( - snapshot.data!.profilePicture))), - ) - : null, - label: Text(snapshot.data!.userName + additionalText, - style: TextStyle(fontSize: size), - overflow: TextOverflow.ellipsis))), + padding: const EdgeInsets.symmetric(horizontal: 2), + child: InputChip( + selected: isSelected, + selectedColor: + selectedColor ?? Theme.of(context).colorScheme.secondary, + onPressed: onTap as void Function()? ?? + () => {_showEditPlayerDialog(context, snapshot.data)}, + avatar: (snapshot.data!.profilePicture != '' && displayImage) + ? Container( + decoration: BoxDecoration( + border: Border.all( + width: 2, + color: Theme.of(context).primaryColor, + ), + shape: BoxShape.circle, + image: DecorationImage( + image: NetworkImage( + snapshot.data!.profilePicture, + ), + ), + ), + ) + : null, + label: Text( + snapshot.data!.userName + additionalText, + style: TextStyle(fontSize: size), + overflow: TextOverflow.ellipsis, + ), + ), + ), ); } if (!snapshot.hasData && snapshot.connectionState == ConnectionState.done) { child = Center( child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: InputChip( - avatar: Container( - decoration: BoxDecoration( - color: Theme.of(context).errorColor, - shape: BoxShape.circle, - )), - label: Text( - AppLocalizations.of(context)!.errorPlayerNotFound))), + padding: const EdgeInsets.symmetric(horizontal: 2), + child: InputChip( + avatar: Container( + decoration: BoxDecoration( + color: Theme.of(context).errorColor, + shape: BoxShape.circle, + )), + label: Text( + AppLocalizations.of(context)!.errorPlayerNotFound, + ), + ), + ), ); } return AnimatedSwitcher( diff --git a/lib/views/widgets/bell_notification_widget.dart b/lib/views/widgets/bell_notification_widget.dart new file mode 100644 index 00000000..5c7a41de --- /dev/null +++ b/lib/views/widgets/bell_notification_widget.dart @@ -0,0 +1,122 @@ +import 'dart:async'; + +import 'package:carg/helpers/custom_route.dart'; +import 'package:carg/models/notification/abstract_notification.dart'; +import 'package:carg/services/impl/notification_service.dart'; +import 'package:carg/views/screens/notifications_screen.dart'; +import 'package:flutter/material.dart'; + +class BellNotificationWidget extends StatefulWidget { + const BellNotificationWidget(); + + @override + State createState() { + return BellNotificationWidgetState(); + } +} + +class BellNotificationWidgetState extends State + with TickerProviderStateMixin { + late AnimationController _dingAnimationController; + late AnimationController _fadeAnimationController; + late Animation _fadeAnimation; + late StreamController> streamController; + final NotificationService notificationService = NotificationService(); + + @override + void initState() { + streamController = StreamController.broadcast(); + _dingAnimationController = AnimationController( + vsync: this, duration: const Duration(milliseconds: 500)); + _fadeAnimationController = AnimationController( + vsync: this, duration: const Duration(milliseconds: 1000)); + notificationService.getAllNotificationsOfUser( + 'id', streamController, NotificationStatus.unread); + _fadeAnimation = CurvedAnimation( + parent: _fadeAnimationController, + curve: Curves.ease, + ); + super.initState(); + } + + @override + void dispose() { + _fadeAnimationController.dispose(); + _dingAnimationController.dispose(); + super.dispose(); + } + + void _runBellAnimation() async { + await _fadeAnimationController.forward(); + await _dingAnimationController.forward(); + await _dingAnimationController.reverse(); + } + + @override + Widget build(BuildContext context) { + return StreamBuilder>( + stream: streamController.stream, + builder: (context, snapshot) { + _runBellAnimation(); + return Stack( + children: [ + RawMaterialButton( + constraints: const BoxConstraints(minHeight: 40, minWidth: 60), + onPressed: () async => { + Navigator.push( + context, + CustomRouteFade( + builder: (context) => NotificationsScreen(), + ), + ) + }, + elevation: 2.0, + fillColor: Theme.of(context).cardColor, + textStyle: TextStyle(color: Theme.of(context).primaryColor), + shape: const CircleBorder(), + child: RotationTransition( + turns: Tween(begin: 0.0, end: -.1) + .chain(CurveTween(curve: Curves.elasticIn)) + .animate(_dingAnimationController), + child: FadeTransition( + opacity: _fadeAnimation, + child: snapshot.hasData && snapshot.data!.isNotEmpty + ? const Icon( + Icons.notifications_active, + ) + : const Icon( + Icons.notifications_none, + ), + ), + ), + ), + snapshot.hasData && snapshot.data!.isNotEmpty + ? Positioned( + right: 10, + child: Container( + padding: const EdgeInsets.all(1), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(6), + ), + constraints: const BoxConstraints( + minWidth: 15, + minHeight: 15, + ), + child: Text( + '${snapshot.data!.length}', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + textAlign: TextAlign.center, + ), + ), + ) + : Container(), + ], + ); + }, + ); + } +} diff --git a/lib/views/widgets/belote_widget.dart b/lib/views/widgets/belote_widget.dart index 5719a9e5..1124a55f 100644 --- a/lib/views/widgets/belote_widget.dart +++ b/lib/views/widgets/belote_widget.dart @@ -38,12 +38,13 @@ class BeloteWidget extends StatelessWidget { roundService, playerService}) : super(key: key) { - this.gameService = gameService ?? CorrectInstance.ofGameService(beloteGame); + this.gameService = + gameService ?? CorrectInstance.ofGameService(beloteGame.gameType); this.scoreService = - scoreService ?? CorrectInstance.ofScoreService(beloteGame); + scoreService ?? CorrectInstance.ofScoreService(beloteGame.gameType); this.teamService = teamService ?? TeamService(); this.roundService = - roundService ?? CorrectInstance.ofRoundService(beloteGame); + roundService ?? CorrectInstance.ofRoundService(beloteGame.gameType); this.playerService = playerService ?? PlayerService(); } diff --git a/lib/views/widgets/players/draggable_player_widget.dart b/lib/views/widgets/players/draggable_player_widget.dart index 20f42f53..378f8364 100644 --- a/lib/views/widgets/players/draggable_player_widget.dart +++ b/lib/views/widgets/players/draggable_player_widget.dart @@ -12,32 +12,44 @@ class DraggablePlayerWidget extends StatelessWidget { @override Widget build(BuildContext context) { return Card( - margin: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), elevation: 2, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10.0), - side: BorderSide(width: 2, color: Theme.of(context).primaryColor)), + borderRadius: BorderRadius.circular(10.0), + side: BorderSide( + width: 2, + color: Theme.of(context).primaryColor, + ), + ), child: Padding( padding: const EdgeInsets.all(10.0), - child: - Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Flexible( - flex: 3, - child: Text( - '#${index + 1}', - style: const TextStyle(fontSize: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + flex: 3, + child: Text( + '#${index + 1}', + style: const TextStyle(fontSize: 20), + ), ), - ), - Flexible( - flex: 5, - child: Text( - player.userName, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 25, fontWeight: FontWeight.bold), + Flexible( + flex: 5, + child: Text( + player.userName, + overflow: TextOverflow.ellipsis, + style: + const TextStyle(fontSize: 25, fontWeight: FontWeight.bold), + ), ), - ), - const Flexible(flex: 2, child: Icon(Icons.drag_indicator, size: 35)) - ]), + const Flexible( + flex: 2, + child: Icon( + Icons.drag_indicator, + size: 35, + ), + ) + ], + ), ), ); } diff --git a/lib/views/widgets/tarot_widget.dart b/lib/views/widgets/tarot_widget.dart index de643e3e..b7f65a1f 100644 --- a/lib/views/widgets/tarot_widget.dart +++ b/lib/views/widgets/tarot_widget.dart @@ -28,9 +28,10 @@ class TarotWidget extends StatelessWidget { scoreService, playerService}) : super(key: key) { - this.gameService = gameService ?? CorrectInstance.ofGameService(tarotGame); + this.gameService = + gameService ?? CorrectInstance.ofGameService(tarotGame.gameType); this.scoreService = - scoreService ?? CorrectInstance.ofScoreService(tarotGame); + scoreService ?? CorrectInstance.ofScoreService(tarotGame.gameType); this.playerService = playerService ?? PlayerService(); } diff --git a/test/widgets/player_info_dialog_test.dart b/test/widgets/player_info_dialog_test.dart index 85b45786..49fa670c 100644 --- a/test/widgets/player_info_dialog_test.dart +++ b/test/widgets/player_info_dialog_test.dart @@ -56,7 +56,7 @@ void main() { expect( tester.widget(find.byKey(const ValueKey('titleText'))).data, - 'Nouveau/nouvelle joueur/joueuse'); + 'Nouveau.elle joueur.euse'); }); testWidgets('INFORMATIONS', (WidgetTester tester) async {