From bee192e335a52ec47cb48493ebe1227ea2ae9595 Mon Sep 17 00:00:00 2001 From: Muyue Date: Fri, 4 Apr 2025 18:40:08 +0200 Subject: [PATCH] Refactor: Nettoyage code, documentation et raffinements finaux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **Cleanup:** Suppression logs de débogage superflus, commentaires obsolètes. Standardisation logging. - **Documentation:** Ajout/Amélioration significative des commentaires JavaDoc (Game, GameStats, MainActivity, etc.). - **Game Logic:** Refactoring des méthodes pushX en processMove. Ajout validation et helpers (isIndexValid). Amélioration robustesse (deserialize, win/loss checks). Classe rendue totalement indépendante du contexte/prefs. - **Stats:** Suppression stat 'perfectGames'. Amélioration formatage temps/moyennes. - **Notifications:** Logique de cooldown/intervalle affinée dans NotificationService. Utilisation centralisée de NotificationHelper. Gestion permission robuste. - **MainActivity:** Refactoring affichage plateau (syncBoardView avec fonds + tuiles). Animation simplifiée (apparition/fusion seulement). Gestion améliorée des états de jeu (PLAYING, WON, GAME_OVER). Logique onResume/onPause/load/save affinée. Placeholders pour Menu/Multi clarifiés. - **UI:** Layouts de dialogues standardisés avec LinearLayout. Permissions AndroidManifest nettoyées. Ajustements mineurs (marges, couleurs, strings, style police). --- app/src/main/AndroidManifest.xml | 15 +- .../main/java/legion/muyue/best2048/Game.java | 565 ++- .../java/legion/muyue/best2048/GameStats.java | 366 +- .../legion/muyue/best2048/MainActivity.java | 2074 ++++++---- .../muyue/best2048/NotificationHelper.java | 144 +- .../muyue/best2048/NotificationService.java | 226 +- sortie.txt | 3570 +++++++++++++++++ 7 files changed, 5888 insertions(+), 1072 deletions(-) create mode 100644 sortie.txt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 60b9b3d..d58a97a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,16 +4,6 @@ - - - - - - - - - - + android:theme="@style/Theme.Best2048" + tools:targetApi="31"> + + = 2048) a été atteinte. */ + /** Indicateur si la condition de victoire (une tuile >= 2048) a été atteinte. */ private boolean gameWon = false; - /** Indicateur si la partie est terminée (plus de mouvements possibles). */ + /** Indicateur si la partie est terminée (plus de mouvements ou de fusions possibles). */ private boolean gameOver = false; /** * Constructeur pour démarrer une nouvelle partie. - * Initialise un plateau vide, le score à 0, et ajoute deux tuiles initiales. + * Initialise un plateau vide, met le score courant à 0, réinitialise les états + * de victoire/défaite, et ajoute deux tuiles initiales aléatoirement. */ public Game() { this.randomNumberGenerator = new Random(); @@ -43,81 +46,145 @@ public class Game { /** * Constructeur pour restaurer une partie à partir d'un état sauvegardé. * Le meilleur score (`highestScore`) doit être défini séparément via {@link #setHighestScore(int)}. - * Recalcule les états `gameWon` et `gameOver` en fonction du plateau fourni. + * Recalcule les états `gameWon` et `gameOver` en fonction du plateau et du score fournis. + * Lance une exception si le plateau fourni n'a pas les bonnes dimensions. * - * @param board Le plateau de jeu restauré. + * @param board Le plateau de jeu restauré. Doit être de dimensions BOARD_SIZE x BOARD_SIZE. * @param score Le score courant restauré. + * @throws IllegalArgumentException si les dimensions du plateau fourni sont incorrectes. */ - public Game(int[][] board, int score) { - // Valider les dimensions du plateau fourni ? Pourrait être ajouté. - this.board = board; + public Game(@NonNull int[][] board, int score) { + // Validation des dimensions du plateau + if (board == null || board.length != BOARD_SIZE || board[0].length != BOARD_SIZE) { + throw new IllegalArgumentException("Le plateau fourni n'a pas les dimensions attendues (" + BOARD_SIZE + "x" + BOARD_SIZE + ")."); + } + this.board = board; // Attention: shallow copy si board est modifié ailleurs après coup. getBoard() est plus sûr. + // Pour la restauration, on suppose que le tableau fourni est destiné à cet usage unique. this.currentScore = score; this.randomNumberGenerator = new Random(); + // Recalculer l'état de victoire/défaite basé sur le plateau chargé checkWinCondition(); - checkGameOverCondition(); + // Vérifier si la partie chargée est déjà terminée + checkGameOverCondition(); // Important si on charge une partie déjà terminée } // --- Getters / Setters --- /** - * Retourne la valeur de la tuile aux coordonnées spécifiées. - * @param row Ligne (0 à BOARD_SIZE-1). - * @param column Colonne (0 à BOARD_SIZE-1). - * @return Valeur de la tuile, ou 0 si les coordonnées sont invalides. + * Retourne la valeur de la tuile aux coordonnées spécifiées (ligne, colonne). + * Les indices sont basés sur 0. + * + * @param row Ligne de la cellule (0 à BOARD_SIZE-1). + * @param column Colonne de la cellule (0 à BOARD_SIZE-1). + * @return La valeur de la tuile, ou 0 si la cellule est vide ou les coordonnées sont invalides. */ public int getCellValue(int row, int column) { if (isIndexValid(row, column)) { return this.board[row][column]; } - return 0; // Retourne 0 pour indice invalide + // Log ou gestion d'erreur pourrait être ajouté si un accès invalide est critique + return 0; // Retourne 0 pour indice invalide comme convention } /** * Définit la valeur d'une tuile aux coordonnées spécifiées. - * Ne fait rien si les coordonnées sont invalides. - * @param row Ligne (0 à BOARD_SIZE-1). - * @param col Colonne (0 à BOARD_SIZE-1). - * @param value Nouvelle valeur de la tuile. + * Utilisé principalement en interne ou pour les tests. Ne fait rien si + * les coordonnées (ligne, colonne) sont en dehors des limites du plateau. + * + * @param row Ligne de la cellule (0 à BOARD_SIZE-1). + * @param col Colonne de la cellule (0 à BOARD_SIZE-1). + * @param value Nouvelle valeur entière pour la tuile. */ - public void setCellValue(int row, int col, int value) { + @VisibleForTesting // Indique que cette méthode est surtout pour les tests + void setCellValue(int row, int col, int value) { if (isIndexValid(row, col)) { this.board[row][col] = value; } } - /** @return Le score actuel de la partie. */ - public int getCurrentScore() { return currentScore; } + /** + * Retourne le score actuel de la partie en cours. + * Le score augmente lors de la fusion de tuiles. + * + * @return Le score entier actuel. + */ + public int getCurrentScore() { + return currentScore; + } - /** @return Le meilleur score connu par cet objet (défini via setHighestScore). */ - public int getHighestScore() { return highestScore; } + /** + * Retourne le meilleur score connu par cette instance de jeu. + * Cette valeur est généralement chargée depuis une sauvegarde externe et mise à jour via {@link #setHighestScore(int)}. + * + * @return Le meilleur score entier connu. + */ + public int getHighestScore() { + return highestScore; + } /** * Met à jour la valeur du meilleur score stockée dans cet objet Game. - * Typiquement appelé par la classe gérant la persistance (MainActivity). - * @param highScore Le meilleur score global à stocker. + * Cette méthode est typiquement appelée par la classe gérant la persistance + * (par exemple, MainActivity) après avoir chargé le meilleur score global. + * + * @param highScore Le nouveau meilleur score global à stocker dans cette instance. */ - public void setHighestScore(int highScore) { this.highestScore = highScore; } - - /** @return true si une tuile 2048 (ou plus) a été atteinte, false sinon. */ - public boolean isGameWon() { return gameWon; } - - /** @return true si aucune case n'est vide ET aucun mouvement/fusion n'est possible, false sinon. */ - public boolean isGameOver() { return gameOver; } - - /** Met à jour la valeur de gameWon si partie gagné **/ - private void setGameWon(boolean won) {this.gameWon = won;} - - /** Met à jour la valeur de gameWon si partie gagné **/ - private void setGameOver(boolean over) {this.gameOver = over;} + public void setHighestScore(int highScore) { + this.highestScore = highScore; + } /** - * Retourne une copie profonde du plateau de jeu actuel. - * Utile pour la sérialisation ou pour éviter des modifications externes non désirées. - * @return Une nouvelle matrice 2D représentant l'état actuel du plateau. + * Vérifie si la condition de victoire a été atteinte (au moins une tuile avec une valeur >= 2048). + * + * @return true si le jeu est considéré comme gagné, false sinon. */ + public boolean isGameWon() { + return gameWon; + } + + /** + * Vérifie si la partie est terminée. + * La partie est terminée si aucune case n'est vide ET aucun mouvement ou fusion n'est possible + * dans aucune des quatre directions. + * + * @return true si la partie est terminée (game over), false sinon. + */ + public boolean isGameOver() { + return gameOver; + } + + /** + * Met à jour l'indicateur interne de victoire du jeu. + * Utilisé après une fusion ou lors de la restauration d'une partie. + * + * @param won true si la condition de victoire est atteinte, false sinon. + */ + private void setGameWon(boolean won) { + this.gameWon = won; + } + + /** + * Met à jour l'indicateur interne de fin de partie (game over). + * Utilisé après une tentative de mouvement infructueuse ou lors de la restauration. + * + * @param over true si la condition de fin de partie est atteinte, false sinon. + */ + private void setGameOver(boolean over) { + this.gameOver = over; + } + + /** + * Retourne une copie profonde (deep copy) du plateau de jeu actuel. + * Ceci est utile pour obtenir l'état du plateau sans risquer de le modifier + * accidentellement de l'extérieur, ou pour la sérialisation. + * + * @return Une nouvelle matrice 2D (`int[BOARD_SIZE][BOARD_SIZE]`) représentant l'état actuel du plateau. + */ + @NonNull public int[][] getBoard() { int[][] copy = new int[BOARD_SIZE][BOARD_SIZE]; - for(int i=0; i emptyCells = findEmptyCells(); if (!emptyCells.isEmpty()) { + // Choisit une cellule vide au hasard parmi celles disponibles int[] randomCell = emptyCells.get(randomNumberGenerator.nextInt(emptyCells.size())); int value = generateRandomTileValue(); + // Place la nouvelle tuile sur le plateau logique + // Utilise setCellValue pour la cohérence, même si l'accès direct serait possible ici setCellValue(randomCell[0], randomCell[1], value); } + // Si emptyCells est vide, le plateau est plein, on ne peut rien ajouter. + // La condition de Game Over sera vérifiée après la tentative de mouvement. } /** - * Trouve toutes les cellules vides sur le plateau. - * @return Une liste de tableaux d'entiers `[row, col]` pour chaque cellule vide. + * Recherche et retourne les coordonnées de toutes les cellules vides (valeur 0) sur le plateau. + * + * @return Une {@link List} de tableaux d'entiers `[row, col]` pour chaque cellule vide trouvée. + * Retourne une liste vide si le plateau est plein. */ + @NonNull private List findEmptyCells() { List emptyCells = new ArrayList<>(); for (int row = 0; row < BOARD_SIZE; row++) { @@ -169,211 +247,368 @@ public class Game { } /** - * Génère la valeur pour une nouvelle tuile en utilisant des probabilités prédéfinies. - * @return La valeur (2, 4, 8, ...). + * Génère la valeur pour une nouvelle tuile (2, 4, 8, ...) en utilisant une distribution + * de probabilités prédéfinie. Les probabilités sont ajustées pour rendre l'apparition + * des tuiles de faible valeur plus fréquente. + * + * Probabilités actuelles (approximatives) : + * - 2: 85.40% + * - 4: 12.00% + * - 8: 2.00% + * - 16: 0.50% + * - 32: 0.05% + * - 64: 0.03% + * - 128: 0.01% + * - 256: 0.01% + * + * @return La valeur entière (2, 4, 8, ...) de la tuile générée. */ private int generateRandomTileValue() { - int randomValue = randomNumberGenerator.nextInt(10000); // Base 10000 pour pourcentages fins - if (randomValue < 8540) return 2; // 85.40% - if (randomValue < 9740) return 4; // 12.00% - if (randomValue < 9940) return 8; // 2.00% - if (randomValue < 9990) return 16; // 0.50% - if (randomValue < 9995) return 32; // 0.05% - if (randomValue < 9998) return 64; // 0.03% - if (randomValue < 9999) return 128;// 0.01% - return 256; // 0.01% + int randomValue = randomNumberGenerator.nextInt(10000); // Base 10000 pour une granularité fine des pourcentages + + // Les seuils définissent les probabilités cumulées + if (randomValue < 8540) return 2; // 0 <= randomValue < 8540 (85.40%) + if (randomValue < 9740) return 4; // 8540 <= randomValue < 9740 (12.00%) + if (randomValue < 9940) return 8; // 9740 <= randomValue < 9940 (2.00%) + if (randomValue < 9990) return 16; // 9940 <= randomValue < 9990 (0.50%) + if (randomValue < 9995) return 32; // 9990 <= randomValue < 9995 (0.05%) + if (randomValue < 9998) return 64; // 9995 <= randomValue < 9998 (0.03%) + if (randomValue < 9999) return 128; // 9998 <= randomValue < 9999 (0.01%) + return 256; // 9999 <= randomValue < 10000 (0.01%) } /** * Tente de déplacer et fusionner les tuiles vers le HAUT. - * Met à jour le score interne et vérifie les états win/gameOver. - * @return true si le plateau a été modifié, false sinon. + * Met à jour le plateau, le score interne, et vérifie les états de victoire/défaite. + * + * @return true si au moins une tuile a bougé ou fusionné, false si le plateau n'a pas changé. */ - public boolean pushUp() { return processMove(MoveDirection.UP); } + public boolean pushUp() { + return processMove(MoveDirection.UP); + } /** * Tente de déplacer et fusionner les tuiles vers le BAS. - * @return true si le plateau a été modifié, false sinon. + * Met à jour le plateau, le score interne, et vérifie les états de victoire/défaite. + * + * @return true si au moins une tuile a bougé ou fusionné, false si le plateau n'a pas changé. */ - public boolean pushDown() { return processMove(MoveDirection.DOWN); } + public boolean pushDown() { + return processMove(MoveDirection.DOWN); + } /** * Tente de déplacer et fusionner les tuiles vers la GAUCHE. - * @return true si le plateau a été modifié, false sinon. + * Met à jour le plateau, le score interne, et vérifie les états de victoire/défaite. + * + * @return true si au moins une tuile a bougé ou fusionné, false si le plateau n'a pas changé. */ - public boolean pushLeft() { return processMove(MoveDirection.LEFT); } + public boolean pushLeft() { + return processMove(MoveDirection.LEFT); + } /** * Tente de déplacer et fusionner les tuiles vers la DROITE. - * @return true si le plateau a été modifié, false sinon. + * Met à jour le plateau, le score interne, et vérifie les états de victoire/défaite. + * + * @return true si au moins une tuile a bougé ou fusionné, false si le plateau n'a pas changé. */ - public boolean pushRight() { return processMove(MoveDirection.RIGHT); } + public boolean pushRight() { + return processMove(MoveDirection.RIGHT); + } - /** Énumération interne pour clarifier le traitement des mouvements. */ + /** Énumération interne pour clarifier la direction du mouvement dans {@link #processMove}. */ private enum MoveDirection { UP, DOWN, LEFT, RIGHT } /** * Méthode générique pour traiter un mouvement (déplacement et fusion) dans une direction donnée. - * Contient la logique de base partagée par les méthodes pushX. - * @param direction La direction du mouvement. - * @return true si le plateau a été modifié, false sinon. + * C'est le cœur de la logique de déplacement du jeu. Elle parcourt le plateau, déplace + * les tuiles jusqu'au bout dans la direction demandée, puis gère les fusions éventuelles. + * + * @param direction La direction du mouvement ({@link MoveDirection}). + * @return true si le plateau a été modifié (au moins un déplacement ou une fusion), false sinon. */ private boolean processMove(MoveDirection direction) { boolean boardChanged = false; - // Itère sur l'axe perpendiculaire au mouvement + + // Itérer sur l'axe perpendiculaire au mouvement (colonnes pour UP/DOWN, lignes pour LEFT/RIGHT) for (int i = 0; i < BOARD_SIZE; i++) { - boolean[] hasMerged = new boolean[BOARD_SIZE]; // Pour éviter double fusion sur l'axe de mouvement - // Itère sur l'axe du mouvement, dans le bon sens + // Tableau pour suivre si une tuile sur la ligne/colonne cible a déjà résulté d'une fusion + // pendant CE mouvement, pour éviter les fusions en chaîne (ex: 2-2-4 -> 4-4 -> 8 en un seul swipe) + boolean[] hasMerged = new boolean[BOARD_SIZE]; + + // Itérer sur l'axe du mouvement. Le sens de parcours est crucial : + // - Pour UP/LEFT: du début vers la fin (index 1 à N-1) car les tuiles fusionnent vers les petits indices. + // - Pour DOWN/RIGHT: de la fin vers le début (index N-2 à 0) car les tuiles fusionnent vers les grands indices. int start = (direction == MoveDirection.DOWN || direction == MoveDirection.RIGHT) ? BOARD_SIZE - 2 : 1; int end = (direction == MoveDirection.DOWN || direction == MoveDirection.RIGHT) ? -1 : BOARD_SIZE; int step = (direction == MoveDirection.DOWN || direction == MoveDirection.RIGHT) ? -1 : 1; for (int j = start; j != end; j += step) { - int row = (direction == MoveDirection.UP || direction == MoveDirection.DOWN) ? j : i; - int col = (direction == MoveDirection.LEFT || direction == MoveDirection.RIGHT) ? j : i; + int row, col; + // Déterminer les coordonnées (row, col) de la tuile courante basée sur l'axe i et l'index j + if (direction == MoveDirection.UP || direction == MoveDirection.DOWN) { + row = j; col = i; // Mouvement vertical, i est la colonne, j est la ligne + } else { + row = i; col = j; // Mouvement horizontal, i est la ligne, j est la colonne + } - if (getCellValue(row, col) != 0) { - int currentValue = getCellValue(row, col); + int currentValue = getCellValue(row, col); + + // Si la case n'est pas vide, on essaie de la déplacer/fusionner + if (currentValue != 0) { int currentRow = row; int currentCol = col; - - // Calcule la position cible après déplacement dans les cases vides - int targetRow = currentRow; + int targetRow = currentRow; // Position cible initiale int targetCol = currentCol; + + // 1. Déplacement : Trouver la case la plus éloignée atteignable dans la direction du mouvement int nextRow = targetRow + ((direction == MoveDirection.UP) ? -1 : (direction == MoveDirection.DOWN) ? 1 : 0); int nextCol = targetCol + ((direction == MoveDirection.LEFT) ? -1 : (direction == MoveDirection.RIGHT) ? 1 : 0); + // Tant que la case suivante est valide et vide, on continue de "glisser" while (isIndexValid(nextRow, nextCol) && getCellValue(nextRow, nextCol) == 0) { targetRow = nextRow; targetCol = nextCol; + // Calculer la case suivante pour la prochaine itération de la boucle while nextRow = targetRow + ((direction == MoveDirection.UP) ? -1 : (direction == MoveDirection.DOWN) ? 1 : 0); nextCol = targetCol + ((direction == MoveDirection.LEFT) ? -1 : (direction == MoveDirection.RIGHT) ? 1 : 0); } - // Déplace la tuile si sa position cible est différente - if (targetRow != currentRow || targetCol != currentCol) { - setCellValue(targetRow, targetCol, currentValue); - setCellValue(currentRow, currentCol, 0); - boardChanged = true; - } - - // Vérifie la fusion potentielle avec la case suivante dans la direction du mouvement - int mergeTargetRow = targetRow + ((direction == MoveDirection.UP) ? -1 : (direction == MoveDirection.DOWN) ? 1 : 0); - int mergeTargetCol = targetCol + ((direction == MoveDirection.LEFT) ? -1 : (direction == MoveDirection.RIGHT) ? 1 : 0); + // 2. Fusion : Vérifier la case immédiatement après la position cible (targetRow, targetCol) + // La case à vérifier pour fusion est `nextRow`, `nextCol` (calculée juste avant ou après la sortie du while) + int mergeTargetRow = nextRow; + int mergeTargetCol = nextCol; + // Index utilisé pour le tableau `hasMerged` (soit la ligne soit la colonne cible de la fusion) int mergeIndex = (direction == MoveDirection.UP || direction == MoveDirection.DOWN) ? mergeTargetRow : mergeTargetCol; + boolean merged = false; + // Vérifier si la case de fusion potentielle est valide, a la même valeur, et n'a pas déjà fusionné if (isIndexValid(mergeTargetRow, mergeTargetCol) && getCellValue(mergeTargetRow, mergeTargetCol) == currentValue && - !hasMerged[mergeIndex]) + !hasMerged[mergeIndex]) // Crucial: empêche double fusion { + // Fusion ! int newValue = currentValue * 2; - setCellValue(mergeTargetRow, mergeTargetCol, newValue); - setCellValue(targetRow, targetCol, 0); // La tuile qui fusionne disparaît - currentScore += newValue; - hasMerged[mergeIndex] = true; - boardChanged = true; + setCellValue(mergeTargetRow, mergeTargetCol, newValue); // Met à jour la case cible de la fusion + setCellValue(currentRow, currentCol, 0); // Vide la case d'origine de la tuile qui a bougé ET fusionné + currentScore += newValue; // Ajoute la *nouvelle* valeur au score + hasMerged[mergeIndex] = true; // Marque la case cible comme ayant fusionné + boardChanged = true; // Le plateau a changé + merged = true; + + // Vérifier immédiatement si cette fusion a créé une tuile >= 2048 + if (newValue >= 2048) { + setGameWon(true); + } } - } - } - } - // Vérifie les conditions de fin après chaque type de mouvement complet - checkWinCondition(); - checkGameOverCondition(); + + // 3. Déplacement final (si pas de fusion OU si la tuile a bougé avant de potentiellement fusionner) + // Si la tuile n'a pas fusionné (elle reste où elle est ou se déplace seulement) + // ET que sa position cible (targetRow, targetCol) est différente de sa position initiale + if (!merged && (targetRow != currentRow || targetCol != currentCol)) { + setCellValue(targetRow, targetCol, currentValue); // Déplace la valeur vers la case cible + setCellValue(currentRow, currentCol, 0); // Vide la case d'origine + boardChanged = true; // Le plateau a changé + } + } // Fin if (currentValue != 0) + } // Fin boucle interne (j) + } // Fin boucle externe (i) + + // Après avoir traité toutes les lignes/colonnes, vérifier l'état global + // Note: checkWinCondition est déjà appelé lors d'une fusion >= 2048, mais on le refait ici par sécurité. + checkWinCondition(); // Vérifie si 2048 a été atteint (peut être déjà fait lors d'une fusion) + checkGameOverCondition(); // Vérifie si plus aucun mouvement n'est possible + return boardChanged; } + /** - * Vérifie si les indices de ligne et colonne sont valides pour le plateau. - * @param row Ligne. - * @param col Colonne. - * @return true si les indices sont dans les limites [0, BOARD_SIZE-1]. + * Vérifie si les indices de ligne et colonne fournis sont valides pour le plateau de jeu actuel. + * + * @param row L'indice de ligne à vérifier. + * @param col L'indice de colonne à vérifier. + * @return true si 0 <= row < BOARD_SIZE et 0 <= col < BOARD_SIZE, false sinon. */ private boolean isIndexValid(int row, int col) { return row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE; } - /** - * Sérialise l'état essentiel du jeu (plateau et score courant) pour sauvegarde. - * Format: "val,val,...,val,score" - * @return Chaîne représentant l'état du jeu. + * Sérialise l'état essentiel du jeu (plateau et score courant) en une chaîne de caractères. + * Le format actuel est simple : toutes les valeurs du plateau séparées par des virgules, + * suivies par le score courant, également séparé par une virgule. + * Exemple pour un plateau 2x2 : "2,0,4,8,12" (où 12 est le score). + *

+ * NOTE : Ce format est simple mais peut être fragile si la structure du jeu + * ou les données à sauvegarder évoluent. Un format comme JSON pourrait offrir plus de flexibilité. + *

+ * + * @return Une chaîne non nulle représentant l'état sérialisé du jeu. */ @NonNull @Override public String toString() { StringBuilder sb = new StringBuilder(); for (int row = 0; row < BOARD_SIZE; row++) { - for (int col = 0; col < BOARD_SIZE; col++) { sb.append(board[row][col]).append(","); } + for (int col = 0; col < BOARD_SIZE; col++) { + sb.append(board[row][col]).append(","); + } } + // Ajoute le score à la fin, séparé par une virgule sb.append(currentScore); return sb.toString(); } /** - * Crée un objet Game à partir de sa représentation sérialisée (plateau + score). - * Le meilleur score doit être défini séparément après la création. - * @param serializedState La chaîne issue de {@link #toString()}. - * @return Une nouvelle instance de Game, ou null en cas d'erreur de format. + * Crée (désérialise) un objet {@code Game} à partir de sa représentation sous forme de chaîne, + * telle que générée par {@link #toString()}. + * Le meilleur score (`highestScore`) n'est pas inclus dans la chaîne et doit être + * défini séparément sur l'objet retourné via {@link #setHighestScore(int)}. + *

+ * NOTE : Cette méthode dépend du format spécifique généré par {@code toString()}. + *

+ * + * @param serializedState La chaîne représentant l'état du jeu (plateau + score courant). + * @return Une nouvelle instance de {@code Game} si la désérialisation réussit, + * ou {@code null} si la chaîne est invalide, vide, ou a un format incorrect + * (mauvais nombre d'éléments, valeur non entière). */ - public static Game deserialize(String serializedState) { - if (serializedState == null || serializedState.isEmpty()) return null; + @Nullable + public static Game deserialize(@Nullable String serializedState) { + if (serializedState == null || serializedState.isEmpty()) { + return null; + } String[] values = serializedState.split(","); - if (values.length != (BOARD_SIZE * BOARD_SIZE + 1)) return null; // +1 pour le score - int[][] newBoard = new int[BOARD_SIZE][BOARD_SIZE]; int index = 0; - try { - for (int row = 0; row < BOARD_SIZE; row++) { - for (int col = 0; col < BOARD_SIZE; col++) { newBoard[row][col] = Integer.parseInt(values[index++]); } - } - int score = Integer.parseInt(values[index]); // Le dernier élément est le score - return new Game(newBoard, score); - } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { return null; } - } + // Vérifie si le nombre d'éléments correspond à la taille du plateau + 1 (pour le score) + if (values.length != (BOARD_SIZE * BOARD_SIZE + 1)) { + return null; + } - /** - * Vérifie si la condition de victoire (une tuile >= 2048) est atteinte. - * Met à jour l'état interne `gameWon`. - */ - private void checkWinCondition() { - if (!gameWon) { - for (int r=0; r= 2048) { - setGameWon(true); return; + int[][] newBoard = new int[BOARD_SIZE][BOARD_SIZE]; + int index = 0; + try { + // Remplit le plateau à partir des valeurs de la chaîne + for (int row = 0; row < BOARD_SIZE; row++) { + for (int col = 0; col < BOARD_SIZE; col++) { + newBoard[row][col] = Integer.parseInt(values[index++]); + } } + // Le dernier élément est le score + int score = Integer.parseInt(values[index]); + + // Crée une nouvelle instance de Game avec les données désérialisées + // Le constructeur recalculera gameWon et gameOver. + return new Game(newBoard, score); + + } catch (ArrayIndexOutOfBoundsException | IllegalArgumentException e) { + // En cas d'erreur de format (valeur non entière, problème d'indice, ou dimension plateau incorrecte dans constructeur) + // Log l'erreur pourrait être utile pour le débogage + System.err.println("Erreur lors de la désérialisation de l'état du jeu: " + e.getMessage()); + return null; } } /** - * Vérifie si la condition de fin de partie est atteinte (plateau plein ET aucun mouvement possible). + * Vérifie si la condition de victoire (au moins une tuile avec une valeur >= 2048) + * est actuellement atteinte sur le plateau. Met à jour l'état interne `gameWon`. + * Cette vérification s'arrête dès qu'une tuile gagnante est trouvée ou si le jeu + * est déjà marqué comme gagné. + */ + private void checkWinCondition() { + // Si le jeu est déjà marqué comme gagné, pas besoin de revérifier + if (gameWon) { + return; + } + // Parcours le plateau à la recherche d'une tuile >= 2048 + for (int r = 0; r < BOARD_SIZE; r++) { + for (int c = 0; c < BOARD_SIZE; c++) { + if (getCellValue(r, c) >= 2048) { + setGameWon(true); // Met à jour l'état interne + return; // Sort dès qu'une condition de victoire est trouvée + } + } + } + // Si la boucle se termine sans trouver de tuile >= 2048, gameWon reste false (ou son état précédent) + } + + /** + * Vérifie si la condition de fin de partie (Game Over) est atteinte. + * Cela se produit si le plateau est plein (pas de case vide) ET si aucune + * fusion n'est possible entre des tuiles adjacentes (horizontalement ou verticalement). * Met à jour l'état interne `gameOver`. */ private void checkGameOverCondition() { - if (hasEmptyCell()) { setGameOver(false); return; } // Pas game over si case vide - // Vérifie s'il existe au moins une fusion possible - for (int r=0; r0 && getCellValue(r-1,c)==current) || (r0 && getCellValue(r,c-1)==current) || (c pas game over + // Si le jeu est déjà terminé, pas besoin de revérifier + if (gameOver) { + return; + } + // Si il y a au moins une case vide, le jeu ne peut pas être terminé + if (hasEmptyCell()) { + setGameOver(false); + return; + } + + // Le plateau est plein. Vérifier s'il existe au moins une fusion possible. + for (int r = 0; r < BOARD_SIZE; r++) { + for (int c = 0; c < BOARD_SIZE; c++) { + int current = getCellValue(r, c); + // Vérifie le voisin du HAUT (si existe) + if (r > 0 && getCellValue(r - 1, c) == current) { + setGameOver(false); return; // Fusion possible vers le haut + } + // Vérifie le voisin du BAS (si existe) + if (r < BOARD_SIZE - 1 && getCellValue(r + 1, c) == current) { + setGameOver(false); return; // Fusion possible vers le bas + } + // Vérifie le voisin de GAUCHE (si existe) + if (c > 0 && getCellValue(r, c - 1) == current) { + setGameOver(false); return; // Fusion possible vers la gauche + } + // Vérifie le voisin de DROITE (si existe) + if (c < BOARD_SIZE - 1 && getCellValue(r, c + 1) == current) { + setGameOver(false); return; // Fusion possible vers la droite + } } } - setGameOver(true); // Aucune case vide et aucune fusion -> game over + + // Si on arrive ici, le plateau est plein ET aucune fusion adjacente n'est possible. + setGameOver(true); } /** - * Vérifie si le plateau contient au moins une case vide (valeur 0). - * @return true si une case vide existe, false sinon. + * Vérifie rapidement si le plateau contient au moins une case vide (valeur 0). + * Utilisé principalement par {@link #checkGameOverCondition()}. + * + * @return true s'il existe au moins une case vide, false si le plateau est plein. */ private boolean hasEmptyCell() { - for (int r=0; r maxTile) maxTile = board[r][c]; + for (int r = 0; r < BOARD_SIZE; r++) { + for (int c = 0; c < BOARD_SIZE; c++) { + if (board[r][c] > maxTile) { + maxTile = board[r][c]; + } + } + } return maxTile; } } \ No newline at end of file diff --git a/app/src/main/java/legion/muyue/best2048/GameStats.java b/app/src/main/java/legion/muyue/best2048/GameStats.java index b74ca24..843e251 100644 --- a/app/src/main/java/legion/muyue/best2048/GameStats.java +++ b/app/src/main/java/legion/muyue/best2048/GameStats.java @@ -1,7 +1,8 @@ -// Fichier GameStats.java /** - * Gère la collecte, la persistance (via SharedPreferences) et l'accès - * aux statistiques du jeu 2048, pour les modes solo et multijoueur (si applicable). + * Gère la collecte, la persistance (via {@link SharedPreferences}) et l'accès + * aux statistiques du jeu 2048. + * Inclut des statistiques générales, solo, et des placeholders pour le multijoueur. + * Charge et sauvegarde automatiquement les statistiques via SharedPreferences. */ package legion.muyue.best2048; @@ -13,9 +14,17 @@ import java.util.concurrent.TimeUnit; public class GameStats { // --- Constantes pour SharedPreferences --- + + /** Nom du fichier de préférences partagées utilisé pour stocker les statistiques et l'état du jeu. */ private static final String PREFS_NAME = "Best2048_Prefs"; - private static final String HIGH_SCORE_KEY = "high_score"; // Clé partagée avec Game/MainActivity - // Clés spécifiques aux statistiques + /** + * Clé pour le meilleur score global. + * NOTE : Cette clé est également utilisée directement par MainActivity/Game pour la sauvegarde/chargement + * de l'état du jeu. La synchronisation est gérée entre les classes. + */ + private static final String HIGH_SCORE_KEY = "high_score"; + + // Clés spécifiques aux statistiques persistantes private static final String STATS_TOTAL_GAMES_PLAYED = "totalGamesPlayed"; private static final String STATS_TOTAL_GAMES_STARTED = "totalGamesStarted"; private static final String STATS_TOTAL_MOVES = "totalMoves"; @@ -23,10 +32,11 @@ public class GameStats { private static final String STATS_TOTAL_MERGES = "totalMerges"; private static final String STATS_HIGHEST_TILE = "highestTile"; private static final String STATS_OBJECTIVE_REACHED_COUNT = "numberOfTimesObjectiveReached"; - private static final String STATS_PERFECT_GAMES = "perfectGames"; + // private static final String STATS_PERFECT_GAMES = "perfectGames"; // Clé supprimée car concept non défini private static final String STATS_BEST_WINNING_TIME_MS = "bestWinningTimeMs"; private static final String STATS_WORST_WINNING_TIME_MS = "worstWinningTimeMs"; - // ... (autres clés stats) ... + + // Clés pour les statistiques multijoueur (fonctionnalité future) private static final String STATS_MP_GAMES_WON = "multiplayerGamesWon"; private static final String STATS_MP_GAMES_PLAYED = "multiplayerGamesPlayed"; private static final String STATS_MP_BEST_WINNING_STREAK = "multiplayerBestWinningStreak"; @@ -37,94 +47,133 @@ public class GameStats { // --- Champs de Statistiques --- - // Générales & Solo - private int totalGamesPlayed; - private int totalGamesStarted; - private int totalMoves; - private long totalPlayTimeMs; - private int totalMerges; - private int highestTile; - private int numberOfTimesObjectiveReached; // Nombre de victoires (>= 2048) - private int perfectGames; // Concept non défini ici - private long bestWinningTimeMs; - private long worstWinningTimeMs; - private int overallHighScore; // Meilleur score global - // Partie en cours (non persistées telles quelles) + // Générales & Solo + /** Nombre total de parties terminées (victoire ou défaite). */ + private int totalGamesPlayed; + /** Nombre total de parties démarrées (via bouton "Nouvelle Partie"). */ + private int totalGamesStarted; + /** Nombre total de mouvements (swipes valides) effectués dans toutes les parties. */ + private int totalMoves; + /** Temps total passé à jouer (en millisecondes) sur toutes les parties. */ + private long totalPlayTimeMs; + /** Nombre total de fusions de tuiles effectuées dans toutes les parties. */ + private int totalMerges; + /** Valeur de la plus haute tuile atteinte globalement dans toutes les parties. */ + private int highestTile; + /** Nombre de fois où l'objectif (atteindre 2048 ou plus) a été atteint. */ + private int numberOfTimesObjectiveReached; + // private int perfectGames; // Champ supprimé car concept non défini + /** Meilleur temps (le plus court, en ms) pour atteindre l'objectif (>= 2048). */ + private long bestWinningTimeMs; + /** Pire temps (le plus long, en ms) pour atteindre l'objectif (>= 2048). */ + private long worstWinningTimeMs; + /** + * Meilleur score global atteint. Persisté via HIGH_SCORE_KEY. + * Synchronisé avec MainActivity/Game lors du chargement/sauvegarde. + */ + private int overallHighScore; + + // Partie en cours (non persistées telles quelles, utilisées pour calculs en temps réel) + /** Nombre de mouvements effectués dans la partie actuelle. */ private int currentMoves; + /** Timestamp (en ms) du début de la partie actuelle. Réinitialisé à chaque nouvelle partie ou reprise. */ private long currentGameStartTimeMs; + /** Nombre de fusions effectuées dans la partie actuelle. */ private int mergesThisGame; - // Multijoueur + // Multijoueur (placeholders pour fonctionnalité future) + /** Nombre de parties multijoueur gagnées. */ private int multiplayerGamesWon; + /** Nombre de parties multijoueur jouées (terminées). */ private int multiplayerGamesPlayed; + /** Plus longue série de victoires consécutives en multijoueur. */ private int multiplayerBestWinningStreak; + /** Score total accumulé dans toutes les parties multijoueur. */ private long multiplayerTotalScore; + /** Temps total passé dans les parties multijoueur (en ms). */ private long multiplayerTotalTimeMs; + /** Nombre total de défaites en multijoueur. */ private int totalMultiplayerLosses; + /** Meilleur score atteint dans une seule partie multijoueur. */ private int multiplayerHighestScore; - /** Contexte nécessaire pour accéder aux SharedPreferences. */ + /** Contexte applicatif nécessaire pour accéder aux SharedPreferences. */ private final Context context; /** - * Constructeur. Initialise l'objet et charge immédiatement les statistiques - * depuis les SharedPreferences. - * @param context Contexte de l'application. + * Constructeur de GameStats. + * Initialise l'objet et charge immédiatement les statistiques persistantes + * depuis les SharedPreferences via {@link #loadStats()}. + * + * @param context Contexte de l'application (Activity ou Application). Utilise `getApplicationContext()` pour éviter les fuites de mémoire. */ public GameStats(Context context) { - this.context = context.getApplicationContext(); // Utilise le contexte applicatif - loadStats(); + this.context = context.getApplicationContext(); // Important: utiliser le contexte applicatif + loadStats(); // Charge les statistiques dès l'initialisation } // --- Persistance (SharedPreferences) --- /** - * Charge toutes les statistiques persistantes depuis les SharedPreferences. - * Appelé par le constructeur. + * Charge toutes les statistiques persistantes depuis le fichier SharedPreferences défini par {@link #PREFS_NAME}. + * Si une clé n'existe pas, une valeur par défaut est utilisée (0, Long.MAX_VALUE pour best time, etc.). + * Appelé automatiquement par le constructeur. */ public void loadStats() { SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + + // Charge le high score (clé partagée) overallHighScore = prefs.getInt(HIGH_SCORE_KEY, 0); + + // Charge les statistiques générales et solo totalGamesPlayed = prefs.getInt(STATS_TOTAL_GAMES_PLAYED, 0); totalGamesStarted = prefs.getInt(STATS_TOTAL_GAMES_STARTED, 0); - // ... (chargement de toutes les autres clés persistantes) ... totalMoves = prefs.getInt(STATS_TOTAL_MOVES, 0); - totalPlayTimeMs = prefs.getLong(STATS_TOTAL_PLAY_TIME_MS, 0); + totalPlayTimeMs = prefs.getLong(STATS_TOTAL_PLAY_TIME_MS, 0L); totalMerges = prefs.getInt(STATS_TOTAL_MERGES, 0); highestTile = prefs.getInt(STATS_HIGHEST_TILE, 0); numberOfTimesObjectiveReached = prefs.getInt(STATS_OBJECTIVE_REACHED_COUNT, 0); - perfectGames = prefs.getInt(STATS_PERFECT_GAMES, 0); - bestWinningTimeMs = prefs.getLong(STATS_BEST_WINNING_TIME_MS, Long.MAX_VALUE); // MAX_VALUE comme défaut pour 'best' - worstWinningTimeMs = prefs.getLong(STATS_WORST_WINNING_TIME_MS, 0); + // perfectGames = prefs.getInt(STATS_PERFECT_GAMES, 0); // Supprimé + bestWinningTimeMs = prefs.getLong(STATS_BEST_WINNING_TIME_MS, Long.MAX_VALUE); // MAX_VALUE comme indicateur "pas encore de temps enregistré" + worstWinningTimeMs = prefs.getLong(STATS_WORST_WINNING_TIME_MS, 0L); + + // Charge les statistiques multijoueur (placeholders) multiplayerGamesWon = prefs.getInt(STATS_MP_GAMES_WON, 0); multiplayerGamesPlayed = prefs.getInt(STATS_MP_GAMES_PLAYED, 0); multiplayerBestWinningStreak = prefs.getInt(STATS_MP_BEST_WINNING_STREAK, 0); - multiplayerTotalScore = prefs.getLong(STATS_MP_TOTAL_SCORE, 0); - multiplayerTotalTimeMs = prefs.getLong(STATS_MP_TOTAL_TIME_MS, 0); + multiplayerTotalScore = prefs.getLong(STATS_MP_TOTAL_SCORE, 0L); + multiplayerTotalTimeMs = prefs.getLong(STATS_MP_TOTAL_TIME_MS, 0L); totalMultiplayerLosses = prefs.getInt(STATS_MP_LOSSES, 0); multiplayerHighestScore = prefs.getInt(STATS_MP_HIGH_SCORE, 0); } /** - * Sauvegarde toutes les statistiques persistantes dans les SharedPreferences. - * Appelé typiquement dans `onPause` de l'activité. + * Sauvegarde toutes les statistiques persistantes actuelles dans le fichier SharedPreferences. + * Utilise `apply()` pour une sauvegarde asynchrone et non bloquante. + * Devrait être appelée lorsque l'application se met en pause ou est détruite, + * ou après une mise à jour significative des statistiques (ex: reset). */ public void saveStats() { SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); SharedPreferences.Editor editor = prefs.edit(); - editor.putInt(HIGH_SCORE_KEY, overallHighScore); // Sauvegarde le HS global + + // Sauvegarde le high score (clé partagée) + editor.putInt(HIGH_SCORE_KEY, overallHighScore); + + // Sauvegarde les statistiques générales et solo editor.putInt(STATS_TOTAL_GAMES_PLAYED, totalGamesPlayed); editor.putInt(STATS_TOTAL_GAMES_STARTED, totalGamesStarted); - // ... (sauvegarde de toutes les autres clés persistantes) ... editor.putInt(STATS_TOTAL_MOVES, totalMoves); editor.putLong(STATS_TOTAL_PLAY_TIME_MS, totalPlayTimeMs); editor.putInt(STATS_TOTAL_MERGES, totalMerges); editor.putInt(STATS_HIGHEST_TILE, highestTile); editor.putInt(STATS_OBJECTIVE_REACHED_COUNT, numberOfTimesObjectiveReached); - editor.putInt(STATS_PERFECT_GAMES, perfectGames); + // editor.putInt(STATS_PERFECT_GAMES, perfectGames); // Supprimé editor.putLong(STATS_BEST_WINNING_TIME_MS, bestWinningTimeMs); editor.putLong(STATS_WORST_WINNING_TIME_MS, worstWinningTimeMs); + + // Sauvegarde les statistiques multijoueur (placeholders) editor.putInt(STATS_MP_GAMES_WON, multiplayerGamesWon); editor.putInt(STATS_MP_GAMES_PLAYED, multiplayerGamesPlayed); editor.putInt(STATS_MP_BEST_WINNING_STREAK, multiplayerBestWinningStreak); @@ -133,24 +182,28 @@ public class GameStats { editor.putInt(STATS_MP_LOSSES, totalMultiplayerLosses); editor.putInt(STATS_MP_HIGH_SCORE, multiplayerHighestScore); - editor.apply(); // Applique les changements de manière asynchrone + // Applique les changements de manière asynchrone + editor.apply(); } // --- Méthodes de Mise à Jour des Statistiques --- /** - * Doit être appelée au début de chaque nouvelle partie. - * Incrémente le compteur de parties démarrées et réinitialise les stats de la partie en cours. + * Doit être appelée au début de chaque nouvelle partie (ex: clic sur "Nouvelle Partie"). + * Incrémente le compteur de parties démarrées (`totalGamesStarted`) et réinitialise + * les statistiques spécifiques à la partie en cours (`currentMoves`, `mergesThisGame`, `currentGameStartTimeMs`). */ public void startGame() { totalGamesStarted++; currentMoves = 0; mergesThisGame = 0; - currentGameStartTimeMs = System.currentTimeMillis(); + currentGameStartTimeMs = System.currentTimeMillis(); // Enregistre le temps de début } /** - * Enregistre un mouvement réussi (qui a modifié le plateau). + * Enregistre un mouvement réussi (un swipe qui a modifié l'état du plateau). + * Incrémente le compteur de mouvements pour la partie en cours (`currentMoves`) + * et le compteur total de mouvements (`totalMoves`). */ public void recordMove() { currentMoves++; @@ -159,7 +212,10 @@ public class GameStats { /** * Enregistre une ou plusieurs fusions survenues lors d'un mouvement. - * @param numberOfMerges Le nombre de fusions (si connu, sinon 1 par défaut). + * Incrémente le compteur de fusions pour la partie en cours (`mergesThisGame`) + * et le compteur total de fusions (`totalMerges`). + * + * @param numberOfMerges Le nombre de fusions distinctes réalisées lors du dernier mouvement (typiquement 1 ou plus). */ public void recordMerge(int numberOfMerges) { if (numberOfMerges > 0) { @@ -169,47 +225,71 @@ public class GameStats { } /** - * Met à jour la statistique de la plus haute tuile atteinte globalement. - * @param tileValue La valeur de la tuile la plus haute de la partie en cours. + * Met à jour la statistique de la plus haute tuile atteinte globalement (`highestTile`), + * si la valeur fournie est supérieure à la valeur actuelle enregistrée. + * Devrait être appelée après chaque mouvement qui pourrait créer une nouvelle tuile max. + * + * @param currentHighestTileValue La valeur de la tuile la plus haute sur le plateau à l'instant T. */ - public void updateHighestTile(int tileValue) { - if (tileValue > this.highestTile) { - this.highestTile = tileValue; + public void updateHighestTile(int currentHighestTileValue) { + if (currentHighestTileValue > this.highestTile) { + this.highestTile = currentHighestTileValue; + // La sauvegarde se fera via saveStats() globalement } } /** - * Enregistre une victoire et met à jour les temps associés. - * @param timeTakenMs Temps écoulé pour cette partie gagnante. + * Enregistre une victoire (atteinte de l'objectif >= 2048). + * Incrémente le compteur de victoires (`numberOfTimesObjectiveReached`) et met à jour + * les statistiques de meilleur/pire temps de victoire si nécessaire. + * Appelle également {@link #endGame(long)} pour finaliser les statistiques générales de la partie. + * + * @param timeTakenMs Temps écoulé (en millisecondes) pour terminer cette partie gagnante. */ public void recordWin(long timeTakenMs) { numberOfTimesObjectiveReached++; - if (timeTakenMs < bestWinningTimeMs) { bestWinningTimeMs = timeTakenMs; } - if (timeTakenMs > worstWinningTimeMs) { worstWinningTimeMs = timeTakenMs; } - endGame(timeTakenMs); // Finalise aussi le temps total et parties jouées + if (timeTakenMs < bestWinningTimeMs) { + bestWinningTimeMs = timeTakenMs; + } + // Utiliser >= pour le pire temps permet de l'enregistrer même si c'est le premier + if (timeTakenMs >= worstWinningTimeMs) { + worstWinningTimeMs = timeTakenMs; + } + endGame(timeTakenMs); // Finalise aussi le temps total et le compteur de parties jouées } /** - * Enregistre une défaite. + * Enregistre une défaite (Game Over - plateau plein sans mouvement possible). + * Calcule le temps écoulé pour la partie et appelle {@link #endGame(long)} + * pour finaliser les statistiques générales. */ public void recordLoss() { - // Calcule le temps écoulé avant de finaliser - endGame(System.currentTimeMillis() - currentGameStartTimeMs); + // Calcule le temps écoulé pour cette partie avant de la finaliser + long timeTakenMs = System.currentTimeMillis() - currentGameStartTimeMs; + endGame(timeTakenMs); } /** * Finalise les statistiques générales à la fin d'une partie (victoire ou défaite). - * @param timeTakenMs Temps total de la partie terminée. + * Incrémente le compteur de parties jouées (`totalGamesPlayed`) et ajoute le temps + * de la partie terminée au temps de jeu total (`totalPlayTimeMs`). + * + * @param timeTakenMs Temps total (en millisecondes) de la partie qui vient de se terminer. */ - public void endGame(long timeTakenMs) { + private void endGame(long timeTakenMs) { totalGamesPlayed++; - addPlayTime(timeTakenMs); + addPlayTime(timeTakenMs); // Ajoute la durée de la partie terminée au total + // Ne pas réinitialiser currentGameStartTimeMs ici, car une nouvelle partie + // pourrait ne pas commencer immédiatement (ex: affichage dialogue Game Over). + // startGame() s'en chargera. } /** - * Ajoute une durée (en ms) au temps de jeu total enregistré. - * Typiquement appelé dans `onPause`. - * @param durationMs Durée à ajouter. + * Ajoute une durée (en millisecondes) au temps de jeu total enregistré (`totalPlayTimeMs`). + * Typiquement appelé dans `onPause` de l'activité pour enregistrer le temps écoulé + * depuis `onResume` ou `startGame`. + * + * @param durationMs Durée positive (en millisecondes) à ajouter au temps total. */ public void addPlayTime(long durationMs) { if (durationMs > 0) { @@ -218,8 +298,10 @@ public class GameStats { } /** - * Réinitialise toutes les statistiques (solo, multijoueur, high score global) - * à leurs valeurs par défaut. + * Réinitialise toutes les statistiques (générales, solo, multijoueur, y compris le high score global) + * à leurs valeurs par défaut (0 ou équivalent). + * Après la réinitialisation, les nouvelles valeurs sont immédiatement sauvegardées + * dans les SharedPreferences via {@link #saveStats()}. */ public void resetStats() { // Réinitialise toutes les variables membres à 0 ou valeur initiale @@ -227,80 +309,158 @@ public class GameStats { totalGamesPlayed = 0; totalGamesStarted = 0; totalMoves = 0; - totalPlayTimeMs = 0; + totalPlayTimeMs = 0L; totalMerges = 0; highestTile = 0; numberOfTimesObjectiveReached = 0; - perfectGames = 0; + // perfectGames = 0; // Supprimé bestWinningTimeMs = Long.MAX_VALUE; - worstWinningTimeMs = 0; + worstWinningTimeMs = 0L; + // Réinitialise les stats multijoueur (placeholders) multiplayerGamesWon = 0; multiplayerGamesPlayed = 0; multiplayerBestWinningStreak = 0; - multiplayerTotalScore = 0; - multiplayerTotalTimeMs = 0; + multiplayerTotalScore = 0L; + multiplayerTotalTimeMs = 0L; totalMultiplayerLosses = 0; multiplayerHighestScore = 0; + // Réinitialise les stats de la partie en cours (au cas où) + currentMoves = 0; + mergesThisGame = 0; + currentGameStartTimeMs = 0L; // Ou System.currentTimeMillis() si on considère que reset démarre une nouvelle session? Non, 0 est plus sûr. + + // Sauvegarde immédiatement les statistiques réinitialisées saveStats(); } // --- Getters pour l'affichage --- + + /** @return Le nombre total de parties terminées (victoire ou défaite). */ public int getTotalGamesPlayed() { return totalGamesPlayed; } + /** @return Le nombre total de parties démarrées. */ public int getTotalGamesStarted() { return totalGamesStarted; } + /** @return Le nombre total de mouvements valides effectués. */ public int getTotalMoves() { return totalMoves; } + /** @return Le nombre de mouvements dans la partie en cours. */ public int getCurrentMoves() { return currentMoves; } + /** @return Le temps total de jeu accumulé (en ms). */ public long getTotalPlayTimeMs() { return totalPlayTimeMs; } - public long getCurrentGameStartTimeMs() { return currentGameStartTimeMs; } // Utile pour calcul durée en cours + /** @return Le timestamp (en ms) du début de la partie en cours. Utile pour calculer la durée en temps réel. */ + public long getCurrentGameStartTimeMs() { return currentGameStartTimeMs; } + /** @return Le nombre de fusions dans la partie en cours. */ public int getMergesThisGame() { return mergesThisGame; } + /** @return Le nombre total de fusions effectuées. */ public int getTotalMerges() { return totalMerges; } + /** @return La valeur de la plus haute tuile atteinte globalement. */ public int getHighestTile() { return highestTile; } + /** @return Le nombre de fois où l'objectif (>= 2048) a été atteint. */ public int getNumberOfTimesObjectiveReached() { return numberOfTimesObjectiveReached; } - public int getPerfectGames() { return perfectGames; } + // public int getPerfectGames() { return perfectGames; } // Supprimé + /** @return Le meilleur temps (le plus court, en ms) pour gagner une partie, ou Long.MAX_VALUE si aucune victoire. */ public long getBestWinningTimeMs() { return bestWinningTimeMs; } + /** @return Le pire temps (le plus long, en ms) pour gagner une partie, ou 0 si aucune victoire. */ public long getWorstWinningTimeMs() { return worstWinningTimeMs; } - public int getOverallHighScore() { return overallHighScore; } // Getter pour HS global - // Getters Multiplayer + /** @return Le meilleur score global enregistré. */ + public int getOverallHighScore() { return overallHighScore; } + + // Getters Multiplayer (fonctionnalité future) + /** @return Nombre de parties multijoueur gagnées. */ public int getMultiplayerGamesWon() { return multiplayerGamesWon; } + /** @return Nombre de parties multijoueur jouées. */ public int getMultiplayerGamesPlayed() { return multiplayerGamesPlayed; } + /** @return Meilleure série de victoires consécutives en multijoueur. */ public int getMultiplayerBestWinningStreak() { return multiplayerBestWinningStreak; } + /** @return Score total accumulé en multijoueur. */ public long getMultiplayerTotalScore() { return multiplayerTotalScore; } + /** @return Temps total passé en multijoueur (en ms). */ public long getMultiplayerTotalTimeMs() { return multiplayerTotalTimeMs; } + /** @return Nombre total de défaites en multijoueur. */ public int getTotalMultiplayerLosses() { return totalMultiplayerLosses; } + /** @return Meilleur score atteint dans une partie multijoueur. */ public int getMultiplayerHighestScore() { return multiplayerHighestScore; } // --- Setters --- - /** Met à jour la valeur interne du high score global. */ - public void setHighestScore(int highScore) { - // Met à jour si la nouvelle valeur est meilleure - if (highScore > this.overallHighScore) { - this.overallHighScore = highScore; - // La sauvegarde se fait via saveStats() globalement - } - } - /** Définit le timestamp de début de la partie en cours. */ - public void setCurrentGameStartTimeMs(long timeMs) { this.currentGameStartTimeMs = timeMs; } - - // --- Méthodes Calculées --- - /** @return Temps moyen par partie terminée en millisecondes. */ - public long getAverageGameTimeMs() { return (totalGamesPlayed > 0) ? totalPlayTimeMs / totalGamesPlayed : 0; } - /** @return Score moyen par partie multijoueur terminée. */ - public int getMultiplayerAverageScore() { return (multiplayerGamesPlayed > 0) ? (int)(multiplayerTotalScore / multiplayerGamesPlayed) : 0; } - /** @return Temps moyen par partie multijoueur terminée en millisecondes. */ - public long getMultiplayerAverageTimeMs() { return (multiplayerGamesPlayed > 0) ? multiplayerTotalTimeMs / multiplayerGamesPlayed : 0; } /** - * Formate une durée en millisecondes en chaîne "hh:mm:ss" ou "mm:ss". - * @param milliseconds Durée en millisecondes. - * @return Chaîne de temps formatée. + * Met à jour la valeur interne du meilleur score global (`overallHighScore`). + * La mise à jour n'a lieu que si le score fourni est strictement supérieur + * au meilleur score actuel. La sauvegarde effective dans SharedPreferences + * se fait via un appel ultérieur à {@link #saveStats()}. + * + * @param highScore Le nouveau score à potentiellement définir comme meilleur score. */ - @SuppressLint("DefaultLocale") + public void setHighestScore(int highScore) { + // Met à jour seulement si la nouvelle valeur est meilleure + if (highScore > this.overallHighScore) { + this.overallHighScore = highScore; + // La sauvegarde se fait via saveStats() globalement, pas ici directement. + } + } + + /** + * Définit le timestamp (en ms) du début de la partie en cours. + * Typiquement utilisé par MainActivity dans `onResume` pour reprendre le chronomètre. + * + * @param timeMs Le timestamp en millisecondes. + */ + public void setCurrentGameStartTimeMs(long timeMs) { + this.currentGameStartTimeMs = timeMs; + } + + // --- Méthodes Calculées --- + + /** + * Calcule le temps moyen passé par partie terminée (solo). + * + * @return Le temps moyen par partie en millisecondes, ou 0 si aucune partie n'a été terminée. + */ + public long getAverageGameTimeMs() { + return (totalGamesPlayed > 0) ? totalPlayTimeMs / totalGamesPlayed : 0L; + } + + /** + * Calcule le score moyen par partie multijoueur terminée. + * (Basé sur les statistiques multijoueur placeholder). + * + * @return Le score moyen entier par partie multijoueur, ou 0 si aucune partie multijoueur n'a été jouée. + */ + public int getMultiplayerAverageScore() { + return (multiplayerGamesPlayed > 0) ? (int)(multiplayerTotalScore / multiplayerGamesPlayed) : 0; + } + + /** + * Calcule le temps moyen passé par partie multijoueur terminée. + * (Basé sur les statistiques multijoueur placeholder). + * + * @return Le temps moyen par partie multijoueur en millisecondes, ou 0 si aucune partie multijoueur n'a été jouée. + */ + public long getMultiplayerAverageTimeMs() { + return (multiplayerGamesPlayed > 0) ? multiplayerTotalTimeMs / multiplayerGamesPlayed : 0L; + } + + /** + * Formate une durée donnée en millisecondes en une chaîne de caractères lisible. + * Le format est "hh:mm:ss" si la durée est d'une heure ou plus, sinon "mm:ss". + * + * @param milliseconds Durée totale en millisecondes. + * @return Une chaîne formatée représentant la durée. + */ + @SuppressLint("DefaultLocale") // Justifié car le format hh:mm:ss est standard et non dépendant de la locale public static String formatTime(long milliseconds) { + if (milliseconds < 0) { milliseconds = 0; } // Gère les cas négatifs + long hours = TimeUnit.MILLISECONDS.toHours(milliseconds); - long minutes = TimeUnit.MILLISECONDS.toMinutes(milliseconds) % 60; - long seconds = TimeUnit.MILLISECONDS.toSeconds(milliseconds) % 60; - if (hours > 0) { return String.format("%02d:%02d:%02d", hours, minutes, seconds); } - else { return String.format("%02d:%02d", minutes, seconds); } + long minutes = TimeUnit.MILLISECONDS.toMinutes(milliseconds) % 60; // Minutes restantes après les heures + long seconds = TimeUnit.MILLISECONDS.toSeconds(milliseconds) % 60; // Secondes restantes après les minutes + + if (hours > 0) { + // Format avec heures si nécessaire + return String.format("%02d:%02d:%02d", hours, minutes, seconds); + } else { + // Format minutes:secondes sinon + return String.format("%02d:%02d", minutes, seconds); + } } } \ No newline at end of file diff --git a/app/src/main/java/legion/muyue/best2048/MainActivity.java b/app/src/main/java/legion/muyue/best2048/MainActivity.java index 2f9915d..065b53e 100644 --- a/app/src/main/java/legion/muyue/best2048/MainActivity.java +++ b/app/src/main/java/legion/muyue/best2048/MainActivity.java @@ -1,17 +1,18 @@ -// Fichier MainActivity.java /** - * Activité principale de l'application 2048. - * Gère l'interface utilisateur (plateau, scores, boutons), coordonne les interactions - * avec la logique du jeu (classe Game) et la gestion des statistiques (classe GameStats). - * Gère également le cycle de vie de l'application et la persistance de l'état du jeu. + * Activité principale de l'application Best 2048. + * Gère l'interface utilisateur (plateau de jeu, affichage des scores, boutons), + * coordonne les interactions de l'utilisateur (swipes, clics) avec la logique du jeu + * (classe {@link Game}) et la gestion des statistiques (classe {@link GameStats}). + * Prend également en charge le cycle de vie de l'application, la persistance de l'état du jeu + * via SharedPreferences, les effets sonores via SoundPool, et les notifications. */ package legion.muyue.best2048; import android.annotation.SuppressLint; import android.app.AlertDialog; import android.content.Context; -import android.app.NotificationChannel; -import android.app.NotificationManager; +import android.app.NotificationChannel; // Conservé pour référence mais createNotificationChannel est dans Helper +import android.app.NotificationManager; // Conservé pour référence mais createNotificationChannel est dans Helper import android.app.PendingIntent; import android.content.ActivityNotFoundException; import android.content.pm.PackageManager; @@ -22,6 +23,7 @@ import android.content.Intent; import android.content.SharedPreferences; import android.net.Uri; import android.os.Bundle; +import android.util.Log; // Utiliser Log pour le débogage import android.util.TypedValue; import android.view.Gravity; import android.view.LayoutInflater; @@ -32,10 +34,11 @@ import android.widget.TextView; import androidx.activity.EdgeToEdge; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; import androidx.core.app.ActivityCompat; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; +import androidx.core.app.NotificationCompat; // Conservé pour référence, mais showNotification est dans Helper +import androidx.core.app.NotificationManagerCompat; // Conservé pour référence, mais showNotification est dans Helper import androidx.core.content.ContextCompat; import androidx.gridlayout.widget.GridLayout; import android.widget.Button; @@ -45,8 +48,8 @@ import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.animation.ObjectAnimator; import android.view.ViewTreeObserver; -import android.media.AudioAttributes; // Pour SoundPool -import android.media.SoundPool; // Pour SoundPool +import android.media.AudioAttributes; +import android.media.SoundPool; import java.util.ArrayList; import java.util.List; @@ -54,134 +57,454 @@ import java.util.List; public class MainActivity extends AppCompatActivity { - // --- UI Elements --- - private GridLayout boardGridLayout; - private TextView currentScoreTextView; - private TextView highestScoreTextView; - private Button newGameButton; - private Button multiplayerButton; - private Button statisticsButton; - private Button menuButton; - private ViewStub statisticsViewStub; - private View inflatedStatsView; - - // --- Game Logic & Stats --- - private Game game; - private GameStats gameStats; + // --- Constantes --- + /** Tag pour les logs de débogage spécifiques à cette activité. */ + private static final String TAG = "MainActivity"; + /** Taille du plateau de jeu (nombre de lignes/colonnes). Doit correspondre à Game.BOARD_SIZE. */ private static final int BOARD_SIZE = 4; + /** Identifiant unique du canal de notification. Doit correspondre à NotificationHelper.CHANNEL_ID. */ private static final String NOTIFICATION_CHANNEL_ID = "BEST_2048_CHANNEL"; - private boolean notificationsEnabled = false; + /** Nom du fichier de préférences partagées. Doit correspondre à GameStats.PREFS_NAME. */ + private static final String PREFS_NAME = "Best2048_Prefs"; + /** Clé pour sauvegarder/charger le meilleur score global. Doit correspondre à GameStats.HIGH_SCORE_KEY. */ + private static final String HIGH_SCORE_KEY = "high_score"; + /** Clé pour sauvegarder/charger l'état sérialisé du jeu (plateau + score courant). */ + private static final String GAME_STATE_KEY = "game_state"; + /** Clé pour sauvegarder/charger le timestamp de la dernière partie jouée (pour les notifications d'inactivité). */ private static final String LAST_PLAYED_TIME_KEY = "last_played_time"; + /** Clé pour sauvegarder/charger la préférence d'activation du son. */ + private static final String SOUND_ENABLED_KEY = "sound_enabled"; + /** Clé pour sauvegarder/charger la préférence d'activation des notifications. */ + private static final String NOTIFICATIONS_ENABLED_KEY = "notifications_enabled"; - // --- State Management --- - private boolean statisticsVisible = false; - private enum GameFlowState { PLAYING, WON_DIALOG_SHOWN, GAME_OVER } - private GameFlowState currentGameState = GameFlowState.PLAYING; - - /** Références aux TextViews des tuiles actuellement affichées. */ + // --- UI Elements --- + /** Layout de grille affichant les tuiles du jeu. */ + private GridLayout boardGridLayout; + /** TextView affichant le score de la partie en cours. */ + private TextView currentScoreTextView; + /** TextView affichant le meilleur score global. */ + private TextView highestScoreTextView; + /** Bouton pour démarrer une nouvelle partie (après confirmation). */ + private Button newGameButton; + /** Bouton pour accéder aux fonctionnalités multijoueur (placeholder actuel). */ + private Button multiplayerButton; + /** Bouton pour afficher/masquer le panneau des statistiques. */ + private Button statisticsButton; + /** Bouton pour ouvrir le menu principal (dialogue). */ + private Button menuButton; + /** ViewStub pour charger paresseusement le layout des statistiques. */ + private ViewStub statisticsViewStub; + /** Référence au layout des statistiques une fois qu'il est gonflé par le ViewStub. */ + private View inflatedStatsView; + /** Références aux TextViews représentant les tuiles actuellement affichées sur le plateau. */ private TextView[][] tileViews = new TextView[BOARD_SIZE][BOARD_SIZE]; + // --- Game Logic & Stats --- + /** Instance de la classe Game contenant la logique métier du jeu (plateau, mouvements, score). */ + private Game game; + /** Instance de la classe GameStats gérant la collecte et la persistance des statistiques. */ + private GameStats gameStats; + + // --- State Management --- + /** Indique si le panneau des statistiques est actuellement visible. */ + private boolean statisticsVisible = false; + /** Énumération pour suivre l'état actuel du flux de jeu (en cours, gagné, perdu). */ + private enum GameFlowState { PLAYING, WON_DIALOG_SHOWN, GAME_OVER } + /** État actuel du flux de jeu. */ + private GameFlowState currentGameState = GameFlowState.PLAYING; + /** Indique si les notifications sont activées par l'utilisateur et si la permission est accordée (sur API 33+). */ + private boolean notificationsEnabled = false; + /** Indique si les effets sonores sont activés par l'utilisateur. */ + private boolean soundEnabled = true; // Son activé par défaut + // --- Preferences --- + /** Instance de SharedPreferences pour la persistance des données. */ private SharedPreferences preferences; - private static final String PREFS_NAME = "Best2048_Prefs"; - private static final String HIGH_SCORE_KEY = "high_score"; - private static final String GAME_STATE_KEY = "game_state"; - // --- Champs Son --- + // --- Sound --- + /** Pool pour gérer et jouer les effets sonores courts. */ private SoundPool soundPool; - private int soundMoveId = -1; // Initialise à -1 pour savoir s'ils sont chargés + /** ID du son chargé pour un mouvement de tuile. */ + private int soundMoveId = -1; + /** ID du son chargé pour une fusion de tuiles. */ private int soundMergeId = -1; + /** ID du son chargé pour la victoire (atteinte de 2048). */ private int soundWinId = -1; + /** ID du son chargé pour la fin de partie (Game Over). */ private int soundGameOverId = -1; - private boolean soundPoolLoaded = false; // Flag pour savoir si les sons sont prêts - private boolean soundEnabled = true; // Son activé par défaut - - // --- Activity Lifecycle --- + /** Indicateur si tous les sons ont été chargés avec succès dans le SoundPool. */ + private boolean soundPoolLoaded = false; + /** + * Lanceur d'activité pour demander la permission POST_NOTIFICATIONS (Android 13+). + * Gère la réponse de l'utilisateur (accordée ou refusée). + */ private final ActivityResultLauncher requestPermissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> { if (isGranted) { - // La permission est accordée. On peut activer/planifier les notifications. + // Permission accordée ! + Log.i(TAG, "Permission POST_NOTIFICATIONS accordée."); notificationsEnabled = true; saveNotificationPreference(true); Toast.makeText(this, R.string.notifications_enabled, Toast.LENGTH_SHORT).show(); - // Ici, on pourrait (re)planifier les notifications périodiques avec WorkManager/AlarmManager + startNotificationService(); // Démarrer le service maintenant que la permission est OK } else { - // La permission est refusée. L'utilisateur ne recevra pas de notifications. + // Permission refusée. + Log.w(TAG, "Permission POST_NOTIFICATIONS refusée."); notificationsEnabled = false; saveNotificationPreference(false); - // Désactive le switch dans les paramètres si l'utilisateur vient de refuser - updateNotificationSwitchState(false); + // Met à jour l'interrupteur dans les paramètres si l'utilisateur vient de refuser + updateNotificationSwitchState(false); // Informe l'utilisateur et met à jour l'UI potentielle Toast.makeText(this, R.string.notifications_disabled, Toast.LENGTH_SHORT).show(); - // Afficher une explication si nécessaire + stopNotificationService(); // Arrêter le service si la permission est refusée + // Optionnel : Afficher une explication pourquoi la notif est utile. // showNotificationPermissionRationale(); } }); + // --- Activity Lifecycle --- + + /** + * Appelée lors de la création initiale de l'activité. + * Configure la vue, initialise les composants (jeu, stats, son, listeners), + * et charge l'état sauvegardé si disponible. + * + * @param savedInstanceState Si l'activité est recréée après avoir été détruite, + * ce Bundle contient les données qu'elle a fournies le plus récemment + * dans onSaveInstanceState(Bundle). Sinon, il est null. + */ @Override protected void onCreate(Bundle savedInstanceState) { - EdgeToEdge.enable(this); + EdgeToEdge.enable(this); // Active l'affichage bord à bord si le thème le supporte super.onCreate(savedInstanceState); - setContentView(R.layout.activity_main); - NotificationHelper.createNotificationChannel(this); - findViews(); - initializeSoundPool(); - initializeGameAndStats(); - setupListeners(); + setContentView(R.layout.activity_main); // Définit le layout de l'activité + + Log.d(TAG, "onCreate: Initialisation de l'activité."); + + // Initialisation des composants + findViews(); // Récupère les références des vues du layout + NotificationHelper.createNotificationChannel(this); // Crée le canal (nécessaire pour API 26+) + initializeSoundPool(); // Charge les effets sonores + initializeGameAndStats(); // Initialise Game, GameStats et charge les données sauvegardées + setupListeners(); // Attache les listeners aux boutons et au plateau + + // Démarrer le service de notification SEULEMENT si les notifications sont activées ET la permission est OK (implicite si < API 33) if (notificationsEnabled) { - startNotificationService(); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || ContextCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { + startNotificationService(); + } else { + Log.w(TAG, "Notifications activées dans les prefs, mais permission manquante sur API 33+. Service non démarré."); + // On pourrait demander la permission ici si on veut être proactif + // requestNotificationPermission(); + } } } /** - * Synchronise COMPLETEMENT le GridLayout avec l'état actuel de 'game.board'. - * Étape 1: Ajoute 16 vues de fond pour "fixer" la structure de la grille. - * Étape 2: Ajoute les TextViews des tuiles réelles (valeur > 0) par-dessus. - * Stocke les références des tuiles réelles dans tileViews. + * Appelée lorsque l'activité devient visible à l'utilisateur. + * Redémarre le chronomètre de la partie si elle était en cours. + * S'assure que le panneau de statistiques est affiché correctement s'il était visible + * avant la mise en pause. + */ + @Override + protected void onResume() { + super.onResume(); + Log.d(TAG, "onResume: Reprise de l'activité."); + + // Redémarre le timer seulement si une partie est activement en cours + if (game != null && gameStats != null && currentGameState == GameFlowState.PLAYING) { + // Utilise le temps sauvegardé lors du dernier onPause s'il existe, + // ou redémarre simplement le chrono. Ici, on redémarre simplement. + gameStats.setCurrentGameStartTimeMs(System.currentTimeMillis()); + Log.d(TAG, "onResume: Chronomètre de partie redémarré."); + } + + // Gère le réaffichage potentiel des statistiques si l'activité reprend + if (statisticsVisible) { + if (inflatedStatsView != null) { // Si déjà gonflé + updateStatisticsTextViews(); // Met à jour les données affichées + inflatedStatsView.setVisibility(View.VISIBLE); // Assure la visibilité + multiplayerButton.setVisibility(View.GONE); // Assure que le bouton multi est masqué + Log.d(TAG, "onResume: Panneau de statistiques ré-affiché."); + } else { + // Si pas encore gonflé (cas rare mais possible), on le fait afficher + // Normalement, toggleStatistics devrait être appelé, mais on force ici pour couvrir le cas. + Log.w(TAG, "onResume: Panneau stats visible mais vue non gonflée. Tentative d'affichage via toggleStatistics."); + statisticsVisible = false; // Force à re-basculer pour gonfler + toggleStatistics(); + } + } else { + if (inflatedStatsView != null) { + inflatedStatsView.setVisibility(View.GONE); // Assure qu'il est masqué si non visible + } + multiplayerButton.setVisibility(View.VISIBLE); // Assure que le bouton multi est visible + } + } + + /** + * Appelée lorsque l'activité est sur le point de passer en arrière-plan. + * Sauvegarde l'état actuel du jeu, les statistiques, et met à jour le temps de jeu total. + * Enregistre également le timestamp actuel comme dernier moment d'activité. + */ + @Override + protected void onPause() { + super.onPause(); + Log.d(TAG, "onPause: Mise en pause de l'activité."); + + // Sauvegarde l'état et les stats si le jeu existe + if (game != null && gameStats != null) { + Log.d(TAG, "onPause: Sauvegarde de l'état du jeu et des statistiques."); + // Met à jour le temps total SI la partie était activement en cours + if (currentGameState == GameFlowState.PLAYING) { + long sessionDuration = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs(); + gameStats.addPlayTime(sessionDuration); // Ajoute le temps de la session au total + Log.d(TAG, "onPause: Temps de jeu ajouté: " + sessionDuration + "ms"); + } + saveGame(); // Sauvegarde l'état du jeu (plateau + score courant) et le HS via prefs + gameStats.saveStats(); // Sauvegarde toutes les statistiques via GameStats (y compris HS) + saveLastPlayedTime(); // Enregistre le moment actuel + } else { + Log.w(TAG, "onPause: Jeu ou GameStats est null, sauvegarde annulée."); + } + } + + /** + * Appelée juste avant que l'activité ne soit détruite. + * Libère les ressources critiques, notamment le SoundPool. + */ + @Override + protected void onDestroy() { + super.onDestroy(); + Log.d(TAG, "onDestroy: Destruction de l'activité."); + // Libère les ressources du SoundPool pour éviter les fuites de mémoire + if (soundPool != null) { + Log.d(TAG, "onDestroy: Libération du SoundPool."); + soundPool.release(); + soundPool = null; + } + // Optionnel: Arrêter le service ici aussi, bien que ce soit moins courant + // stopNotificationService(); + } + + // --- Initialisation --- + + /** + * Récupère les références des vues principales du layout (activity_main.xml) via leur ID. + * Doit être appelée dans onCreate après setContentView. + */ + private void findViews() { + Log.d(TAG, "findViews: Récupération des références des vues."); + boardGridLayout = findViewById(R.id.gameBoard); + currentScoreTextView = findViewById(R.id.scoreLabel); + highestScoreTextView = findViewById(R.id.highScoreLabel); + newGameButton = findViewById(R.id.restartButton); + statisticsButton = findViewById(R.id.statsButton); + menuButton = findViewById(R.id.menuButton); + multiplayerButton = findViewById(R.id.multiplayerButton); + statisticsViewStub = findViewById(R.id.statsViewStub); + // inflatedStatsView sera initialisé lorsque le ViewStub est gonflé + } + + /** + * Initialise les objets Game et GameStats. + * Charge les préférences utilisateur (son, notifications). + * Charge l'état du jeu sauvegardé (s'il existe) et synchronise le meilleur score. + * Met à jour l'interface utilisateur initiale. Si aucun jeu n'est chargé, démarre une nouvelle partie. + */ + private void initializeGameAndStats() { + Log.d(TAG, "initializeGameAndStats: Initialisation du jeu et des statistiques."); + preferences = getSharedPreferences(PREFS_NAME, MODE_PRIVATE); + + // Charger les préférences utilisateur avant d'initialiser les objets qui en dépendent + loadSoundPreference(); + loadNotificationPreference(); + + // Initialiser GameStats (qui charge ses propres données depuis SharedPreferences) + gameStats = new GameStats(this); + + // Charger l'état du jeu (Game) s'il existe + loadGame(); // Tente de charger 'game' depuis SharedPreferences + + // Si loadGame n'a pas réussi à charger une partie valide (ou s'il n'y avait pas de sauvegarde) + if (game == null) { + Log.i(TAG, "initializeGameAndStats: Aucun état de jeu valide trouvé, démarrage d'une nouvelle partie."); + startNewGame(); // Crée une nouvelle instance de 'game' et initialise l'UI + } else { + Log.i(TAG, "initializeGameAndStats: État de jeu chargé avec succès."); + // Assure que le meilleur score dans l'objet Game chargé est synchronisé avec GameStats + game.setHighestScore(gameStats.getOverallHighScore()); + updateUI(); // Met à jour l'UI avec le jeu chargé + } + // L'état 'currentGameState' est défini dans loadGame ou startNewGame + Log.d(TAG, "initializeGameAndStats: État initial du jeu: " + currentGameState); + } + + /** + * Initialise le {@link SoundPool} et charge les différents effets sonores + * utilisés dans le jeu depuis les ressources raw. + * Met en place un listener pour savoir quand les sons sont prêts à être joués. + */ + private void initializeSoundPool() { + Log.d(TAG, "initializeSoundPool: Initialisation du SoundPool."); + // Configuration pour les effets sonores de jeu + AudioAttributes attributes = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_GAME) // Type d'usage approprié + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) // Type de contenu + .build(); + + // Crée le SoundPool avec un nombre maximum de flux simultanés + soundPool = new SoundPool.Builder() + .setMaxStreams(3) // Permet de jouer jusqu'à 3 sons en même temps + .setAudioAttributes(attributes) + .build(); + + // Listener pour détecter la fin du chargement des sons + soundPool.setOnLoadCompleteListener((soundPool, sampleId, status) -> { + if (status == 0) { + // Succès du chargement pour 'sampleId' + Log.d(TAG, "SoundPool: Son ID " + sampleId + " chargé avec succès."); + // Pourrait vérifier si tous les sons sont chargés ici, mais un flag global est plus simple + // Vérifier si tous les ID attendus sont > 0 avant de mettre le flag + if (soundMoveId > 0 && soundMergeId > 0 && soundWinId > 0 && soundGameOverId > 0) { + Log.i(TAG, "SoundPool: Tous les sons sont chargés."); + soundPoolLoaded = true; + } + } else { + // Échec du chargement + Log.e(TAG, "SoundPool: Échec du chargement du son ID " + sampleId + ", status: " + status); + // On pourrait désactiver le son ou juste ignorer ce son spécifique + soundEnabled = false; // Désactiver globalement par sécurité en cas d'échec + saveSoundPreference(false); // Sauvegarde la désactivation + } + }); + + // Charge les sons depuis res/raw. Les IDs retournés sont stockés. + // Le 3ème argument (priority) est généralement 1 (priorité normale). + try { + Log.d(TAG, "SoundPool: Chargement des sons..."); + soundMoveId = soundPool.load(this, R.raw.move, 1); + soundMergeId = soundPool.load(this, R.raw.merge, 1); + soundWinId = soundPool.load(this, R.raw.win, 1); + soundGameOverId = soundPool.load(this, R.raw.game_over, 1); + // Vérifier immédiatement si les ID sont valides (load retourne 0 en cas d'erreur interne) + if (soundMoveId == 0 || soundMergeId == 0 || soundWinId == 0 || soundGameOverId == 0) { + throw new RuntimeException("SoundPool.load a retourné 0, échec du chargement d'un son."); + } + } catch (Exception e) { + Log.e(TAG, "SoundPool: Erreur lors du chargement des sons.", e); + // Gérer l'erreur, par exemple désactiver complètement le son + soundEnabled = false; + saveSoundPreference(false); + Toast.makeText(this, "Erreur au chargement des sons, son désactivé.", Toast.LENGTH_LONG).show(); + } + } + + /** + * Configure les listeners pour les éléments interactifs : + * - Boutons (Nouvelle Partie, Stats, Menu, Multijoueur) + * - Plateau de jeu (détection des swipes via {@link OnSwipeTouchListener}). + */ + private void setupListeners() { + Log.d(TAG, "setupListeners: Configuration des listeners."); + // Listener pour le bouton Nouvelle Partie + newGameButton.setOnClickListener(v -> { + v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press)); // Effet visuel + showRestartConfirmationDialog(); // Affiche dialogue de confirmation + }); + + // Listener pour le bouton Statistiques + statisticsButton.setOnClickListener(v -> { + v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press)); + toggleStatistics(); // Affiche/Masque le panneau + }); + + // Listener pour le bouton Menu + menuButton.setOnClickListener(v -> { + v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press)); + showMenu(); // Affiche le dialogue du menu principal + }); + + // Listener pour le bouton Multijoueur (placeholder) + multiplayerButton.setOnClickListener(v -> { + v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press)); + showMultiplayerScreen(); // Affiche dialogue placeholder + }); + + // Attache le listener de swipe au GridLayout du plateau + setupSwipeListener(); + } + + // --- Mise à jour UI --- + + /** + * Met à jour l'ensemble de l'interface utilisateur pour refléter l'état actuel + * du jeu et des statistiques. Appelle {@link #syncBoardView()} et {@link #updateScores()}. + */ + private void updateUI() { + Log.d(TAG, "updateUI: Mise à jour de l'interface utilisateur."); + if (game == null) { + Log.w(TAG, "updateUI: Tentative de mise à jour avec 'game' null."); + return; // Ne rien faire si le jeu n'est pas initialisé + } + // Utilise syncBoardView pour redessiner le plateau de manière robuste + syncBoardView(); + // Met à jour les labels de score + updateScores(); + // Si les statistiques sont visibles, les mettre à jour aussi + if (statisticsVisible && inflatedStatsView != null) { + updateStatisticsTextViews(); + } + } + + /** + * Synchronise COMPLÈTEMENT le {@link GridLayout} (boardGridLayout) avec l'état actuel + * du plateau logique dans l'objet {@link Game} (game.board). + * Cette méthode est conçue pour être appelée après chaque modification logique du plateau. + * Étapes : + * 1. Supprime toutes les vues existantes du GridLayout. + * 2. Réinitialise le tableau `tileViews` qui stocke les références aux TextViews des tuiles. + * 3. Ajoute 16 vues de fond ({@code View}) pour structurer la grille visuellement. + * 4. Ajoute les {@code TextView} pour chaque tuile réelle (valeur > 0) par-dessus les fonds, + * en utilisant {@link #createTileTextView(int, int, int)}. + * 5. Stocke les références des TextViews des tuiles réelles dans `tileViews`. + * 6. Met à jour l'affichage textuel des scores via {@link #updateScores()}. */ private void syncBoardView() { // Vérifications de sécurité if (game == null || boardGridLayout == null) { - System.err.println("syncBoardView: Game ou GridLayout est null !"); + Log.e(TAG, "syncBoardView: Impossible de synchroniser, Game ou GridLayout est null !"); return; } - // Log.d("SyncDebug", "Syncing board view (Background + Tiles)..."); + // Log.d(TAG, "syncBoardView: Synchronisation du plateau..."); // --- Réinitialisation --- boardGridLayout.removeAllViews(); // Vide complètement le GridLayout visuel - // Réinitialise le tableau qui stocke les références aux *vraies* tuiles (pas les fonds) + // Réinitialise le tableau qui stocke les références aux *vraies* tuiles for (int r = 0; r < BOARD_SIZE; r++) { for (int c = 0; c < BOARD_SIZE; c++) { tileViews[r][c] = null; } } - // Récupère la marge une seule fois + // Récupère la marge et le contexte une seule fois pour l'optimisation int gridMargin = (int) getResources().getDimension(R.dimen.tile_margin); + Context context = this; // Contexte de l'activité // --- Étape 1: Ajouter les 16 vues de fond pour définir la grille --- for (int r = 0; r < BOARD_SIZE; r++) { for (int c = 0; c < BOARD_SIZE; c++) { - // Utiliser un simple View pour le fond est suffisant - View backgroundCell = new View(this); + // Utiliser un simple View pour le fond + View backgroundCell = new View(context); - // Appliquer le style d'une cellule vide - // Utilise le même drawable que les tuiles pour les coins arrondis, mais avec la couleur de fond vide + // Appliquer le style d'une cellule vide (fond + coins arrondis) backgroundCell.setBackgroundResource(R.drawable.tile_background); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - // Utiliser setTintList pour la compatibilité et éviter de recréer des drawables - backgroundCell.getBackground().setTintList(ContextCompat.getColorStateList(this, R.color.tile_empty)); - } else { - // Pour les versions plus anciennes, une approche différente pourrait être nécessaire si setTintList n'est pas dispo - // ou utiliser un drawable spécifique pour le fond. Ici on suppose API 21+ pour setTintList. - // Alternative simple mais moins propre : backgroundCell.setBackgroundColor(ContextCompat.getColor(this, R.color.tile_empty)); (perd les coins arrondis) - } + backgroundCell.getBackground().setTintList(ContextCompat.getColorStateList(context, R.color.tile_empty)); - // Définir les LayoutParams pour positionner cette vue de fond dans la grille + // Définir les LayoutParams pour positionner cette vue de fond GridLayout.LayoutParams params = new GridLayout.LayoutParams(); - params.width = 0; - params.height = 0; + params.width = 0; // Largeur gérée par poids + params.height = 0; // Hauteur gérée par poids // Spécifier ligne, colonne, span=1, et poids=1 pour occuper la cellule params.rowSpec = GridLayout.spec(r, 1, 1f); params.columnSpec = GridLayout.spec(c, 1, 1f); @@ -198,50 +521,52 @@ public class MainActivity extends AppCompatActivity { for (int c = 0; c < BOARD_SIZE; c++) { int value = game.getCellValue(r, c); // Récupère la valeur logique - // Si la case logique contient une tuile réelle + // Si la case logique contient une tuile réelle (valeur > 0) if (value > 0) { - // Crée la TextView stylisée pour cette tuile via notre méthode helper - // createTileTextView utilise les mêmes LayoutParams (row, col, span 1, weight 1, margins) + // Crée la TextView stylisée pour cette tuile via la méthode helper TextView tileTextView = createTileTextView(value, r, c); - // Stocke la référence à cette TextView de tuile (pas la vue de fond) + // Stocke la référence à cette TextView de tuile tileViews[r][c] = tileTextView; - // Ajoute la TextView de la tuile au GridLayout. - // Comme elle est ajoutée après la vue de fond pour la même cellule (r,c) - // et utilise les mêmes paramètres de positionnement, elle s'affichera par-dessus. + // Ajoute la TextView de la tuile au GridLayout. Elle se superposera au fond. boardGridLayout.addView(tileTextView); - // Log.d("SyncDebug", "Added TILE view at ["+r+","+c+"] with value "+value); + // Log.d(TAG, "syncBoardView: Ajout de la tuile [" + r + "," + c + "] valeur " + value); } } } - // Log.d("SyncDebug", "Board sync finished."); + // Log.d(TAG, "syncBoardView: Synchronisation terminée."); - // Met à jour l'affichage textuel des scores + // Met à jour l'affichage textuel des scores après avoir redessiné le plateau updateScores(); } /** - * Crée et configure une TextView pour une tuile. - * @param value Valeur de la tuile. - * @param row Ligne. - * @param col Colonne. - * @return La TextView configurée. + * Crée et configure une {@link TextView} pour représenter une tuile du jeu. + * Applique le style visuel approprié (couleur, taille de texte) basé sur la valeur + * de la tuile via {@link #setTileStyle(TextView, int)}. + * Configure les {@link GridLayout.LayoutParams} pour positionner correctement la tuile + * dans la grille avec les marges appropriées. + * + * @param value La valeur numérique de la tuile (doit être > 0). + * @param row L'indice de la ligne (0 à BOARD_SIZE-1) où la tuile doit être placée. + * @param col L'indice de la colonne (0 à BOARD_SIZE-1) où la tuile doit être placée. + * @return La {@code TextView} configurée et prête à être ajoutée au GridLayout. */ + @NonNull private TextView createTileTextView(int value, int row, int col) { TextView tileTextView = new TextView(this); - setTileStyle(tileTextView, value); // Applique le style visuel + setTileStyle(tileTextView, value); // Applique le style visuel (couleur, taille texte) + // Configure les LayoutParams pour le positionnement dans le GridLayout GridLayout.LayoutParams params = new GridLayout.LayoutParams(); - params.width = 0; // Largeur gérée par GridLayout basé sur la colonne/poids - params.height = 0; // Hauteur gérée par GridLayout basé sur la ligne/poids - - // --- Modification : Spécifier explicitement le span (taille) à 1 --- - // Utilisation de spec(start, size, weight) - params.rowSpec = GridLayout.spec(row, 1, 1f); // Commence à 'row', occupe 1 ligne, poids 1 - params.columnSpec = GridLayout.spec(col, 1, 1f); // Commence à 'col', occupe 1 colonne, poids 1 - // --- Fin Modification --- + params.width = 0; // Largeur gérée par poids + params.height = 0; // Hauteur gérée par poids + // Spécifie la position (ligne, colonne), le span (1x1) et le poids (1f) + params.rowSpec = GridLayout.spec(row, 1, 1f); + params.columnSpec = GridLayout.spec(col, 1, 1f); + // Applique les marges externes int margin = (int) getResources().getDimension(R.dimen.tile_margin); params.setMargins(margin, margin, margin, margin); tileTextView.setLayoutParams(params); @@ -249,269 +574,64 @@ public class MainActivity extends AppCompatActivity { return tileTextView; } - @Override - protected void onResume() { - super.onResume(); - // Redémarre le timer seulement si le jeu est en cours (pas gagné/perdu) - if (game != null && gameStats != null && currentGameState == GameFlowState.PLAYING) { - gameStats.setCurrentGameStartTimeMs(System.currentTimeMillis()); - } - // Gère le réaffichage potentiel des stats si l'activité reprend - if (statisticsVisible) { - if (inflatedStatsView != null) { // Si déjà gonflé - updateStatisticsTextViews(); // Met à jour les données affichées - inflatedStatsView.setVisibility(View.VISIBLE); - multiplayerButton.setVisibility(View.GONE); - } else { - // Si pas encore gonflé (cas rare mais possible), on le fait afficher - toggleStatistics(); - } - } - } - - @Override - protected void onPause() { - super.onPause(); - // Sauvegarde l'état et les stats si le jeu existe - if (game != null && gameStats != null) { - // Met à jour le temps total SI la partie était en cours - if (currentGameState == GameFlowState.PLAYING) { - gameStats.addPlayTime(System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs()); // Utilise méthode GameStats - } - saveGame(); // Sauvegarde l'état du jeu (plateau + score courant) et le HS - gameStats.saveStats(); // Sauvegarde toutes les stats via GameStats - } - } - - /** Sauvegarde le timestamp actuel comme dernier moment joué. */ - private void saveLastPlayedTime() { - if (preferences != null) { - preferences.edit().putLong(LAST_PLAYED_TIME_KEY, System.currentTimeMillis()).apply(); - } - } - - @Override - protected void onDestroy() { // Important de libérer SoundPool - super.onDestroy(); - if (soundPool != null) { - soundPool.release(); - soundPool = null; - } - } - - // --- Initialisation --- /** - * Récupère les références des vues du layout principal via leur ID. - */ - private void findViews() { - boardGridLayout = findViewById(R.id.gameBoard); - currentScoreTextView = findViewById(R.id.scoreLabel); - highestScoreTextView = findViewById(R.id.highScoreLabel); - newGameButton = findViewById(R.id.restartButton); - statisticsButton = findViewById(R.id.statsButton); - menuButton = findViewById(R.id.menuButton); - multiplayerButton = findViewById(R.id.multiplayerButton); - statisticsViewStub = findViewById(R.id.statsViewStub); - } - - /** - * Initialise les objets Game et GameStats. - * Charge l'état du jeu sauvegardé (s'il existe) et le meilleur score. - * Met à jour l'interface utilisateur initiale. - */ - private void initializeGameAndStats() { - preferences = getSharedPreferences(PREFS_NAME, MODE_PRIVATE); - gameStats = new GameStats(this); - loadNotificationPreference(); - loadSoundPreference(); - loadGame(); // Charge jeu et met à jour high score - updateUI(); - if (game == null) { - // Si loadGame échoue ou aucune sauvegarde, startNewGame gère l'initialisation - startNewGame(); - } - // L'état (currentGameState) est défini dans loadGame ou startNewGame - } - - /** Initialise le SoundPool et charge les effets sonores. */ - private void initializeSoundPool() { - // Configuration pour les effets sonores de jeu - AudioAttributes attributes = new AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_GAME) - .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) - .build(); - - // Crée le SoundPool - soundPool = new SoundPool.Builder() - .setMaxStreams(3) // Nombre max de sons joués simultanément - .setAudioAttributes(attributes) - .build(); - - // Listener pour savoir quand les sons sont chargés - soundPool.setOnLoadCompleteListener((soundPool, sampleId, status) -> { - if (status == 0) { - // Vérifie si TOUS les sons sont chargés (ou gère individuellement) - // Ici, on met juste un flag général pour simplifier - soundPoolLoaded = true; - } - }); - - // Charge les sons depuis res/raw - // Le 3ème argument (priority) est 1 (priorité normale) - try { - soundMoveId = soundPool.load(this, R.raw.move, 1); - soundMergeId = soundPool.load(this, R.raw.merge, 1); - soundWinId = soundPool.load(this, R.raw.win, 1); - soundGameOverId = soundPool.load(this, R.raw.game_over, 1); - } catch (Exception e) { - // Gérer l'erreur, peut-être désactiver le son - soundEnabled = false; - } - } - - // --- Préférences Son --- - - /** Sauvegarde la préférence d'activation du son. */ - private void saveSoundPreference(boolean enabled) { - if (preferences != null) { - preferences.edit().putBoolean("sound_enabled", enabled).apply(); - } - } - - /** Charge la préférence d'activation du son. */ - private void loadSoundPreference() { - if (preferences != null) { - soundEnabled = preferences.getBoolean("sound_enabled", true); // Son activé par défaut - } else { - soundEnabled = true; - } - } - - // --- Lecture Son --- - - /** - * Joue un effet sonore si le SoundPool est chargé et si le son est activé. - * @param soundId L'ID du son retourné par soundPool.load(). - */ - private void playSound(int soundId) { - if (soundPoolLoaded && soundEnabled && soundPool != null && soundId > 0) { - // Arguments: soundID, leftVolume, rightVolume, priority, loop, rate - soundPool.play(soundId, 1.0f, 1.0f, 1, 0, 1.0f); - } - } - - /** Crée le canal de notification nécessaire pour Android 8.0+. */ - private void createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - CharSequence name = getString(R.string.notification_channel_name); - String description = getString(R.string.notification_channel_description); - int importance = NotificationManager.IMPORTANCE_DEFAULT; - NotificationChannel channel = new NotificationChannel(NOTIFICATION_CHANNEL_ID, name, importance); - channel.setDescription(description); - // Enregistre le canal avec le système; ne peut pas être changé après ça - NotificationManager notificationManager = getSystemService(NotificationManager.class); - if (notificationManager != null) { - notificationManager.createNotificationChannel(channel); - } - } - } - - /** - * Configure les listeners pour les boutons et le plateau de jeu (swipes). - * Mise à jour pour le bouton Menu. - */ - private void setupListeners() { - newGameButton.setOnClickListener(v -> { - v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press)); - showRestartConfirmationDialog(); - }); - statisticsButton.setOnClickListener(v -> { - v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press)); - toggleStatistics(); - }); - // Modifié pour appeler la nouvelle méthode showMenu() - menuButton.setOnClickListener(v -> { - v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press)); - showMenu(); // Appelle la méthode du menu - }); - multiplayerButton.setOnClickListener(v -> { - v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press)); - showMultiplayerScreen(); // Affiche dialogue placeholder - }); - setupSwipeListener(); - } - - // --- Mise à jour UI --- - - /** - * Met à jour complètement l'interface utilisateur (plateau et scores). - */ - private void updateUI() { - if (game == null) return; - updateBoard(); - updateScores(); - } - - /** - * Redessine le plateau de jeu en créant/mettant à jour les TextViews des tuiles. - */ - private void updateBoard() { - boardGridLayout.removeAllViews(); - for (int row = 0; row < BOARD_SIZE; row++) { - for (int col = 0; col < BOARD_SIZE; col++) { - TextView tileTextView = new TextView(this); - int value = game.getCellValue(row, col); - setTileStyle(tileTextView, value); - // Définit les LayoutParams pour que la tuile remplisse la cellule du GridLayout - GridLayout.LayoutParams params = new GridLayout.LayoutParams(); - params.width = 0; params.height = 0; // Poids gère la taille - params.rowSpec = GridLayout.spec(row, 1f); // Prend 1 fraction de l'espace en hauteur - params.columnSpec = GridLayout.spec(col, 1f); // Prend 1 fraction de l'espace en largeur - int margin = (int) getResources().getDimension(R.dimen.tile_margin); - params.setMargins(margin, margin, margin, margin); - tileTextView.setLayoutParams(params); - boardGridLayout.addView(tileTextView); - } - } - } - - /** - * Met à jour les TextViews affichant le score courant et le meilleur score. + * Met à jour les TextViews affichant le score courant et le meilleur score global. + * Utilise les valeurs actuelles de l'objet {@link Game}. */ private void updateScores() { - currentScoreTextView.setText(getString(R.string.score_placeholder, game.getCurrentScore())); - highestScoreTextView.setText(getString(R.string.high_score_placeholder, game.getHighestScore())); + if (game != null && currentScoreTextView != null && highestScoreTextView != null) { + // Log.d(TAG, "updateScores: Score=" + game.getCurrentScore() + ", HighScore=" + game.getHighestScore()); + currentScoreTextView.setText(getString(R.string.score_placeholder, game.getCurrentScore())); + highestScoreTextView.setText(getString(R.string.high_score_placeholder, game.getHighestScore())); + } else { + Log.w(TAG, "updateScores: Impossible de mettre à jour les scores (game ou TextViews null)."); + } } /** - * Applique le style visuel (fond, texte, taille) à une TextView représentant une tuile. - * @param tileTextView La TextView de la tuile. - * @param value La valeur numérique de la tuile (0 pour vide). + * Applique le style visuel (couleur de fond, couleur de texte, taille de texte) + * à une {@link TextView} représentant une tuile, en fonction de sa valeur numérique. + * Gère également le cas des cases vides (valeur 0). + * + * @param tileTextView La TextView de la tuile à styliser. + * @param value La valeur numérique de la tuile (0 pour une case vide). */ - private void setTileStyle(TextView tileTextView, int value) { + private void setTileStyle(@NonNull TextView tileTextView, int value) { + // Définit le texte (valeur numérique ou chaîne vide pour 0) tileTextView.setText(value > 0 ? String.valueOf(value) : ""); - tileTextView.setGravity(Gravity.CENTER); - tileTextView.setTypeface(null, android.graphics.Typeface.BOLD); - int backgroundColorId; int textColorId; int textSizeId; + tileTextView.setGravity(Gravity.CENTER); // Centre le texte + tileTextView.setTypeface(null, android.graphics.Typeface.BOLD); // Texte en gras + + int backgroundColorId; + int textColorId; + int textSizeId; + + // Sélectionne les couleurs et la taille de texte en fonction de la valeur de la tuile switch (value) { - case 0: backgroundColorId = R.color.tile_empty; textColorId = android.R.color.transparent; textSizeId = R.dimen.text_size_tile_small; break; + case 0: backgroundColorId = R.color.tile_empty; textColorId = android.R.color.transparent; textSizeId = R.dimen.text_size_tile_small; break; // Case vide case 2: backgroundColorId = R.color.tile_2; textColorId = R.color.text_tile_low; textSizeId = R.dimen.text_size_tile_small; break; case 4: backgroundColorId = R.color.tile_4; textColorId = R.color.text_tile_low; textSizeId = R.dimen.text_size_tile_small; break; case 8: backgroundColorId = R.color.tile_8; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_small; break; case 16: backgroundColorId = R.color.tile_16; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_small; break; case 32: backgroundColorId = R.color.tile_32; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_small; break; case 64: backgroundColorId = R.color.tile_64; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_small; break; - case 128: backgroundColorId = R.color.tile_128; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_medium; break; + case 128: backgroundColorId = R.color.tile_128; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_medium; break; // Taille texte réduite pour 3 chiffres case 256: backgroundColorId = R.color.tile_256; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_medium; break; case 512: backgroundColorId = R.color.tile_512; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_medium; break; - case 1024: backgroundColorId = R.color.tile_1024; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_large; break; + case 1024: backgroundColorId = R.color.tile_1024; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_large; break; // Taille texte encore réduite pour 4 chiffres case 2048: backgroundColorId = R.color.tile_2048; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_large; break; - default: backgroundColorId = R.color.tile_super; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_large; break; + default: backgroundColorId = R.color.tile_super; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_large; break; // Tuiles > 2048 } + + // Applique le fond (drawable avec coins arrondis) et la teinte tileTextView.setBackgroundResource(R.drawable.tile_background); tileTextView.getBackground().setTint(ContextCompat.getColor(this, backgroundColorId)); + + // Applique la couleur du texte tileTextView.setTextColor(ContextCompat.getColor(this, textColorId)); + + // Applique la taille du texte (convertie de sp/dp en pixels) tileTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, getResources().getDimension(textSizeId)); } @@ -519,34 +639,59 @@ public class MainActivity extends AppCompatActivity { // --- Gestion des Actions Utilisateur --- /** - * Configure le listener pour détecter les swipes sur le plateau de jeu. + * Configure le listener pour détecter les gestes de balayage (swipe) sur le + * {@link GridLayout} représentant le plateau de jeu. Utilise une instance + * de {@link OnSwipeTouchListener}. */ - @SuppressLint("ClickableViewAccessibility") + @SuppressLint("ClickableViewAccessibility") // Nécessaire car setOnTouchListener est utilisé private void setupSwipeListener() { + if (boardGridLayout == null) { + Log.e(TAG, "setupSwipeListener: boardGridLayout est null, impossible d'attacher le listener."); + return; + } + Log.d(TAG, "setupSwipeListener: Configuration du listener de swipe sur le plateau."); boardGridLayout.setOnTouchListener(new OnSwipeTouchListener(this, new OnSwipeTouchListener.SwipeListener() { - @Override public void onSwipeTop() { handleSwipe(Direction.UP); } - @Override public void onSwipeBottom() { handleSwipe(Direction.DOWN); } - @Override public void onSwipeLeft() { handleSwipe(Direction.LEFT); } - @Override public void onSwipeRight() { handleSwipe(Direction.RIGHT); } + @Override public void onSwipeTop() { handleSwipe(Direction.UP); } + @Override public void onSwipeBottom() { handleSwipe(Direction.DOWN); } + @Override public void onSwipeLeft() { handleSwipe(Direction.LEFT); } + @Override public void onSwipeRight() { handleSwipe(Direction.RIGHT); } })); } /** - * Traite un swipe : met à jour la logique, synchronise l'affichage instantanément, - * puis lance des animations locales d'apparition/fusion sur les tuiles concernées. - * @param direction Direction du swipe. + * Gère un geste de swipe détecté sur le plateau. + * 1. Vérifie si le jeu est dans un état où un mouvement est autorisé. + * 2. Appelle la méthode de logique de jeu appropriée (`game.pushX()`). + * 3. Si le plateau a changé : + * a. Joue les sons appropriés (mouvement, fusion). + * b. Enregistre les statistiques (mouvement, fusion, tuile max, score). + * c. Ajoute une nouvelle tuile logique via `game.addNewTile()`. + * d. Met à jour l'affichage COMPLET du plateau via `syncBoardView()`. + * e. Lance les animations locales (apparition, fusion) sur les vues mises à jour via `animateChanges()`. + * 4. Si le plateau n'a pas changé, vérifie quand même si c'est un état de Game Over. + * 5. La vérification finale des conditions de victoire/défaite est déléguée à `checkEndGameConditions()`, + * appelée après la fin des animations. + * + * @param direction La direction du swipe détecté ({@link Direction}). */ private void handleSwipe(Direction direction) { - // Bloque si jeu terminé + Log.d(TAG, "handleSwipe: Geste détecté -> " + direction); + + // Bloque les mouvements si le jeu n'est pas initialisé ou est terminé if (game == null || gameStats == null || currentGameState == GameFlowState.GAME_OVER) { + Log.d(TAG, "handleSwipe: Mouvement ignoré (jeu non prêt ou terminé). State: " + currentGameState); return; } - // Note: On ne bloque plus sur 'isAnimating' pour cette approche + // On pourrait aussi bloquer pendant une animation si nécessaire, mais l'approche actuelle + // met à jour l'UI puis anime, ce qui rend un blocage moins critique. + // Sauvegarde l'état avant le mouvement pour les animations et la détection de changement int scoreBefore = game.getCurrentScore(); - int[][] boardBeforePush = game.getBoard(); // État avant le push + int[][] boardBeforePush = game.getBoard(); // Copie profonde de l'état avant - boolean boardChanged = false; + boolean boardChanged = false; // Indicateur si le mouvement a modifié le plateau logique + + // Appelle la méthode logique correspondante dans Game switch (direction) { case UP: boardChanged = game.pushUp(); break; case DOWN: boardChanged = game.pushDown(); break; @@ -554,261 +699,454 @@ public class MainActivity extends AppCompatActivity { case RIGHT: boardChanged = game.pushRight(); break; } + // Si le mouvement a effectivement modifié quelque chose sur le plateau logique... if (boardChanged) { - playSound(soundMoveId); - // Capture l'état APRÈS le push mais AVANT l'ajout de la nouvelle tuile + Log.d(TAG, "handleSwipe: Le plateau a changé."); + playSound(soundMoveId); // Joue le son de mouvement + + // Capture l'état APRÈS le push mais AVANT l'ajout de la nouvelle tuile (utile pour animer les fusions) int[][] boardAfterPush = game.getBoard(); - // Met à jour les stats générales - gameStats.recordMove(); + // --- Mise à jour des statistiques --- + gameStats.recordMove(); // Enregistre le mouvement int scoreAfter = game.getCurrentScore(); - if (scoreAfter > scoreBefore) { - playSound(soundMergeId); - gameStats.recordMerge(1); // Simplifié + if (scoreAfter > scoreBefore) { // Si le score a augmenté, une fusion a eu lieu + Log.d(TAG, "handleSwipe: Fusion détectée (score " + scoreBefore + " -> " + scoreAfter + ")"); + playSound(soundMergeId); // Joue le son de fusion + // Simplifié : on compte 1 pour chaque mouvement entraînant une augmentation de score. + // Pour être précis, il faudrait que game.pushX retourne le nombre de fusions. + gameStats.recordMerge(1); // TODO: Améliorer pour compter précisément les fusions si nécessaire + + // Vérifie et met à jour le meilleur score si nécessaire if (scoreAfter > game.getHighestScore()) { - game.setHighestScore(scoreAfter); - gameStats.setHighestScore(scoreAfter); + Log.i(TAG, "handleSwipe: Nouveau High Score! " + scoreAfter); + game.setHighestScore(scoreAfter); // Met à jour dans l'objet Game + gameStats.setHighestScore(scoreAfter); // Met à jour dans GameStats (sera sauvegardé via saveStats) + // On pourrait envoyer une notif de nouveau HS ici si désiré et permis + // showNotification(this, "Nouveau Record !", "Meilleur score : " + scoreAfter, 4); } } + // Met à jour la stat de la plus haute tuile atteinte globalement gameStats.updateHighestTile(game.getHighestTileValue()); - // updateScores(); // Le score sera mis à jour par syncBoardView - // Ajoute la nouvelle tuile dans la logique du jeu + // --- Ajout de la nouvelle tuile --- + Log.d(TAG, "handleSwipe: Ajout d'une nouvelle tuile..."); game.addNewTile(); - int[][] boardAfterAdd = game.getBoard(); // État final logique + int[][] boardAfterAdd = game.getBoard(); // État logique final après ajout - // *** Synchronise l'affichage avec l'état final logique *** - syncBoardView(); + // --- Mise à jour VISUELLE du plateau --- + Log.d(TAG, "handleSwipe: Synchronisation de la vue du plateau..."); + syncBoardView(); // Redessine TOUT le plateau basé sur boardAfterAdd - // *** Lance les animations locales sur les vues mises à jour *** - animateChanges(boardBeforePush, boardAfterPush, boardAfterAdd); + // --- Lancement des ANIMATIONS locales --- + Log.d(TAG, "handleSwipe: Lancement des animations..."); + animateChanges(boardBeforePush, boardAfterPush, boardAfterAdd); // Anime apparition/fusion - // La vérification de fin de partie est maintenant dans animateChanges->finalizeMove - // pour s'assurer qu'elle est faite APRÈS les animations. + // La vérification de fin de partie (checkEndGameConditions) est maintenant appelée + // à la fin des animations dans animateChanges. } else { - // Mouvement invalide, on vérifie quand même si c'est la fin du jeu + // Le mouvement n'a rien changé sur le plateau logique. + Log.d(TAG, "handleSwipe: Mouvement invalide (aucune tuile n'a bougé)."); + // Vérifier si c'est un état de Game Over (plateau plein et bloqué) + // Note: game.pushX() a déjà appelé checkGameOverCondition, on vérifie juste le flag. if (game.isGameOver() && currentGameState != GameFlowState.GAME_OVER) { - currentGameState = GameFlowState.GAME_OVER; + Log.i(TAG, "handleSwipe: Mouvement invalide ET état Game Over détecté."); + currentGameState = GameFlowState.GAME_OVER; // Met à jour l'état long timeTaken = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs(); - gameStats.recordLoss(); gameStats.endGame(timeTaken); - showGameOverDialog(); + gameStats.recordLoss(); // Enregistre la défaite + // gameStats.endGame(timeTaken); // endGame est appelé par recordLoss/recordWin + showGameOverDialog(); // Affiche le dialogue de fin } } } /** - * Identifie les tuiles qui ont fusionné ou sont apparues et lance des animations - * simples (scale/alpha) sur les vues correspondantes DÉJÀ positionnées par syncBoardView. - * @param boardBeforePush État avant le déplacement/fusion. - * @param boardAfterPush État après déplacement/fusion, avant ajout nouvelle tuile. - * @param boardAfterAdd État final après ajout nouvelle tuile. + * Joue un effet sonore via le SoundPool, si celui-ci est chargé, + * si le son est globalement activé par l'utilisateur (`soundEnabled`), + * si le SoundPool lui-même est initialisé et si l'ID du son est valide. + * Ne fait rien si une de ces conditions n'est pas remplie. + * + * @param soundId L'ID entier du son à jouer, tel que retourné par {@code soundPool.load()}. + * Doit être supérieur à 0 pour être valide. */ - private void animateChanges(int[][] boardBeforePush, int[][] boardAfterPush, int[][] boardAfterAdd) { - List animations = new ArrayList<>(); + private void playSound(int soundId) { + // Vérifie toutes les conditions avant de tenter de jouer le son + if (soundPoolLoaded && soundEnabled && soundPool != null && soundId > 0) { + // Arguments de soundPool.play: + // soundID: L'ID du son chargé. + // leftVolume: Volume pour le canal gauche (0.0 à 1.0). + // rightVolume: Volume pour le canal droit (0.0 à 1.0). + // priority: Priorité du flux (0 = la plus basse). Non utilisé pour la dépréemption. + // loop: Mode de boucle (-1 = boucle infinie, 0 = ne pas boucler, >0 = nombre de répétitions). + // rate: Vitesse de lecture (1.0 = vitesse normale, 0.5 = demi-vitesse, 2.0 = double vitesse). + soundPool.play(soundId, 1.0f, 1.0f, 1, 0, 1.0f); + // Log.d(TAG, "playSound: Joué son ID " + soundId); // Décommenter pour débogage + } else { + // Log pour déboguer pourquoi le son n'est pas joué + /* + Log.d(TAG, "playSound: Son ID " + soundId + " non joué. Conditions: " + + "soundPoolLoaded=" + soundPoolLoaded + + ", soundEnabled=" + soundEnabled + + ", soundPool != null=" + (soundPool != null) + + ", soundId > 0=" + (soundId > 0)); + */ + } + } + /** + * Analyse les différences entre l'état du plateau avant et après un mouvement + * pour identifier les tuiles qui sont apparues ou qui ont fusionné. + * Lance des animations locales (scale, alpha) sur les {@link TextView} correspondantes + * qui ont DÉJÀ été mises à jour et positionnées par {@link #syncBoardView()}. + * À la fin de l'ensemble des animations, appelle {@link #checkEndGameConditions()}. + * + * @param boardBeforePush État du plateau (valeurs) avant le début du mouvement/fusion. + * @param boardAfterPush État du plateau après le mouvement/fusion mais AVANT l'ajout de la nouvelle tuile. + * @param boardAfterAdd État final du plateau après l'ajout de la nouvelle tuile. + */ + private void animateChanges(@NonNull int[][] boardBeforePush, @NonNull int[][] boardAfterPush, @NonNull int[][] boardAfterAdd) { + List animations = new ArrayList<>(); // Liste pour regrouper les animations + + // Parcourt le plateau final for (int r = 0; r < BOARD_SIZE; r++) { for (int c = 0; c < BOARD_SIZE; c++) { - TextView currentView = tileViews[r][c]; // Vue à la position finale (après syncBoardView) - if (currentView == null) continue; // Pas de vue à animer ici + TextView currentView = tileViews[r][c]; // Récupère la vue TextView à la position finale [r][c] - int valueAfterAdd = boardAfterAdd[r][c]; - int valueAfterPush = boardAfterPush[r][c]; // Valeur avant l'ajout - int valueBeforePush = boardBeforePush[r][c]; // Valeur tout au début + // Si pas de vue à cette position (case vide dans l'état final), on passe + if (currentView == null) continue; - // 1. Animation d'Apparition - // Si la case était vide après le push, mais a une valeur maintenant (c'est la nouvelle tuile) + int valueAfterAdd = boardAfterAdd[r][c]; // Valeur finale + int valueAfterPush = boardAfterPush[r][c]; // Valeur intermédiaire (après fusion, avant ajout) + // int valueBeforePush = boardBeforePush[r][c]; // Valeur initiale à [r][c] (pas toujours utile ici) + + // Trouver la ou les tuiles d'origine qui ont mené à cette position + // C'est complexe. Simplifions : animons basé sur ce qui s'est passé à la position [r][c] + + // 1. Animation d'Apparition : + // Si la case [r][c] était vide après le push, mais a une valeur maintenant (après add), + // c'est la NOUVELLE tuile qui vient d'apparaître à cette position. if (valueAfterPush == 0 && valueAfterAdd > 0) { - //Log.d("AnimationDebug", "Animating APPEAR at ["+r+","+c+"]"); - currentView.setScaleX(0.3f); currentView.setScaleY(0.3f); currentView.setAlpha(0f); - Animator appear = createAppearAnimation(currentView); - animations.add(appear); + // Log.d(TAG, "animateChanges: Animation APPARITION pour [" + r + "," + c + "]"); + // Prépare la vue pour l'animation (petite et transparente) + currentView.setScaleX(0.3f); + currentView.setScaleY(0.3f); + currentView.setAlpha(0f); + // Crée et ajoute l'animation d'apparition + animations.add(createAppearAnimation(currentView)); } - // 2. Animation de Fusion - // Si la valeur a changé PENDANT le push (valeur après push > valeur avant push) - // ET que la case n'était pas vide avant (ce n'est pas un simple déplacement) - else if (valueAfterPush > valueBeforePush && valueBeforePush != 0) { - //Log.d("AnimationDebug", "Animating MERGE at ["+r+","+c+"]"); - Animator merge = createMergeAnimation(currentView); - animations.add(merge); + // 2. Animation de Fusion : + // Si la valeur à [r][c] APRÈS le push est plus grande que la valeur AVANT le push + // à CETTE MÊME position [r][c] (cela signifie qu'une fusion s'est produite ICI). + // Note: Cette condition est approximative. Une tuile peut s'être déplacée PUIS avoir fusionné ailleurs. + // Ou une tuile peut avoir fusionné DANS cette case. + // Pour une animation de "pulse" simple sur la tuile résultante de la fusion : + boolean fusionOccurredAtThisPosition = false; + // Vérifier si la valeur a augmenté à cette position pendant la phase de push/merge + if (valueAfterPush > boardBeforePush[r][c] && boardBeforePush[r][c] != 0) { + fusionOccurredAtThisPosition = true; } - // Note : les tuiles qui ont simplement bougé ne sont pas animées ici. - // Les tuiles qui ont disparu (fusionnées vers une autre case) sont gérées par syncBoardView qui les supprime. + // Vérifier aussi si une tuile s'est déplacée vers ici et a fusionné (valeur avant=0, valeur après push > 0 ET différente de la valeur après add si c'était la nouvelle tuile) + // C'est complexe. L'approche la plus simple est d'animer si la valeur a augmenté par rapport à l'état AVANT le push. + else { + // Chercher si une tuile adjacente de même valeur a disparu lors du push + // ... logique complexe ... + } + + // Si on détecte une fusion à cette position [r][c] (par augmentation de valeur par ex) + // ET que ce n'est pas la nouvelle tuile qui vient d'apparaître (géré au-dessus) + if (fusionOccurredAtThisPosition && !(valueAfterPush == 0 && valueAfterAdd > 0)) { + // Log.d(TAG, "animateChanges: Animation FUSION pour [" + r + "," + c + "]"); + // Crée et ajoute l'animation de fusion (pulse) + animations.add(createMergeAnimation(currentView)); + } + + // Note : Les tuiles qui ont simplement bougé ne sont pas explicitement animées ici. + // L'effet visuel vient du fait que syncBoardView les a placées à leur nouvelle position. } } + // Si des animations ont été créées, on les joue ensemble if (!animations.isEmpty()) { + Log.d(TAG, "animateChanges: Démarrage de " + animations.size() + " animations."); AnimatorSet animatorSet = new AnimatorSet(); - animatorSet.playTogether(animations); + animatorSet.playTogether(animations); // Joue toutes les animations en parallèle animatorSet.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { - // finalizeMove n'est plus responsable du déblocage, mais vérifie la fin + // Appelée quand TOUTES les animations de l'AnimatorSet sont terminées + Log.d(TAG, "animateChanges: Fin des animations."); + // Vérifie maintenant les conditions de fin de partie checkEndGameConditions(); } }); - animatorSet.start(); + animatorSet.start(); // Démarre l'ensemble des animations } else { - // Si aucune animation n'a été générée (ex: mouvement sans fusion ni nouvelle tuile possible) - checkEndGameConditions(); // Vérifie quand même la fin de partie + // Si aucune animation n'a été générée (ex: mouvement simple sans fusion ni nouvelle tuile) + Log.d(TAG, "animateChanges: Aucune animation à jouer."); + // Vérifie quand même immédiatement les conditions de fin de partie + checkEndGameConditions(); } } - /** Crée une animation d'apparition (scale + alpha). */ - private Animator createAppearAnimation(View view) { + /** + * Crée une animation d'apparition pour une vue (typiquement une nouvelle tuile). + * L'animation combine une augmentation de taille (scale) et une apparition en fondu (alpha). + * + * @param view La vue (TextView de la tuile) à animer. + * @return Un objet {@link Animator} représentant l'animation d'apparition. + */ + private Animator createAppearAnimation(@NonNull View view) { + // Animation de 0.3 à 1.0 pour la largeur et la hauteur ObjectAnimator scaleX = ObjectAnimator.ofFloat(view, "scaleX", 0.3f, 1f); ObjectAnimator scaleY = ObjectAnimator.ofFloat(view, "scaleY", 0.3f, 1f); + // Animation de 0.0 (transparent) à 1.0 (opaque) pour l'alpha ObjectAnimator alpha = ObjectAnimator.ofFloat(view, "alpha", 0f, 1f); + + // Regroupe les animations pour les jouer en même temps AnimatorSet set = new AnimatorSet(); set.playTogether(scaleX, scaleY, alpha); - set.setDuration(150); // Durée apparition - return set; - } - - /** Crée une animation de 'pulse' pour une fusion. */ - private Animator createMergeAnimation(View view) { - ObjectAnimator scaleX = ObjectAnimator.ofFloat(view, "scaleX", 1f, 1.2f, 1f); - ObjectAnimator scaleY = ObjectAnimator.ofFloat(view, "scaleY", 1f, 1.2f, 1f); - AnimatorSet set = new AnimatorSet(); - set.playTogether(scaleX, scaleY); - set.setDuration(120); // Durée pulse fusion + set.setDuration(150); // Durée courte pour l'apparition (en ms) return set; } /** - * Vérifie si le jeu est gagné ou perdu et affiche le dialogue approprié. - * Doit être appelé après la fin des animations potentielles. + * Crée une animation de "pulse" (léger grossissement puis retour à la normale) + * pour une vue (typiquement une tuile résultant d'une fusion). + * + * @param view La vue (TextView de la tuile fusionnée) à animer. + * @return Un objet {@link Animator} représentant l'animation de fusion. */ - private void checkEndGameConditions() { - if (game == null || currentGameState == GameFlowState.GAME_OVER) return; + private Animator createMergeAnimation(@NonNull View view) { + // Animation de 1.0 à 1.2 puis retour à 1.0 pour la largeur et la hauteur + ObjectAnimator scaleX = ObjectAnimator.ofFloat(view, "scaleX", 1f, 1.2f, 1f); + ObjectAnimator scaleY = ObjectAnimator.ofFloat(view, "scaleY", 1f, 1.2f, 1f); - if (game.isGameWon() && currentGameState == GameFlowState.PLAYING) { - currentGameState = GameFlowState.WON_DIALOG_SHOWN; - long timeTaken = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs(); - gameStats.recordWin(timeTaken); - showGameWonKeepPlayingDialog(); - // La notif est déjà envoyée dans handleSwipe si on la veut immédiate - } else if (game.isGameOver()) { - currentGameState = GameFlowState.GAME_OVER; - long timeTaken = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs(); - gameStats.recordLoss(); - gameStats.endGame(timeTaken); - showGameOverDialog(); - } + // Regroupe les animations + AnimatorSet set = new AnimatorSet(); + set.playTogether(scaleX, scaleY); + set.setDuration(120); // Durée très courte pour l'effet "pulse" (en ms) + return set; } - /** Énumération pour les directions de swipe. */ + /** + * Vérifie les conditions de fin de partie (Victoire ou Game Over) après qu'un + * mouvement (et ses animations éventuelles) a été complété. + * Met à jour l'état du jeu (`currentGameState`) et affiche les dialogues appropriés. + * Enregistre également les statistiques de victoire/défaite. + */ + private void checkEndGameConditions() { + Log.d(TAG, "checkEndGameConditions: Vérification de l'état de fin de partie."); + if (game == null || gameStats == null) { + Log.w(TAG, "checkEndGameConditions: Jeu ou Stats non initialisé."); + return; + } + + // Vérifier la condition de VICTOIRE + // On vérifie si le jeu est gagné ET si on n'a pas déjà montré le dialogue de victoire + if (game.isGameWon() && currentGameState == GameFlowState.PLAYING) { + Log.i(TAG, "checkEndGameConditions: Victoire détectée !"); + currentGameState = GameFlowState.WON_DIALOG_SHOWN; // Marque que le dialogue va être montré + long timeTaken = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs(); + gameStats.recordWin(timeTaken); // Enregistre la victoire et met à jour les temps + showGameWonKeepPlayingDialog(); // Affiche le dialogue "Gagné / Continuer ?" + // Si une notification d'accomplissement est souhaitée : + showAchievementNotification(game.getHighestTileValue()); + + // Vérifier la condition de GAME OVER (si pas déjà gagné ou terminé) + } else if (game.isGameOver() && currentGameState != GameFlowState.GAME_OVER) { + Log.i(TAG, "checkEndGameConditions: Game Over détecté !"); + currentGameState = GameFlowState.GAME_OVER; // Met à jour l'état + long timeTaken = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs(); + gameStats.recordLoss(); // Enregistre la défaite + // gameStats.endGame(timeTaken); // Est appelé par recordLoss + showGameOverDialog(); // Affiche le dialogue "Game Over" + + // Sinon, le jeu continue normalement + } else { + Log.d(TAG, "checkEndGameConditions: Le jeu continue."); + } + // Sauvegarder l'état après chaque vérification pourrait être utile + // saveGame(); + // gameStats.saveStats(); + } + + /** Énumération interne simple pour représenter les directions de swipe. */ private enum Direction { UP, DOWN, LEFT, RIGHT } // --- Dialogues --- /** - * Affiche la boîte de dialogue demandant confirmation avant de redémarrer. + * Affiche une boîte de dialogue demandant à l'utilisateur de confirmer + * s'il souhaite réellement redémarrer la partie en cours. + * Utilise le layout personnalisé `dialog_restart_confirm.xml`. */ private void showRestartConfirmationDialog() { + Log.d(TAG, "Affichage dialogue confirmation redémarrage."); AlertDialog.Builder builder = new AlertDialog.Builder(this); LayoutInflater inflater = getLayoutInflater(); - View dialogView = inflater.inflate(R.layout.dialog_restart_confirm, null); + View dialogView = inflater.inflate(R.layout.dialog_restart_confirm, null); // Gonfle le layout personnalisé builder.setView(dialogView); + // Empêche de fermer en cliquant à l'extérieur + builder.setCancelable(false); + Button cancelButton = dialogView.findViewById(R.id.dialogCancelButton); Button confirmButton = dialogView.findViewById(R.id.dialogConfirmButton); - final AlertDialog dialog = builder.create(); - cancelButton.setOnClickListener(v -> dialog.dismiss()); - confirmButton.setOnClickListener(v -> { dialog.dismiss(); startNewGame(); }); - dialog.show(); + + final AlertDialog dialog = builder.create(); // Crée la dialogue mais ne l'affiche pas encore + + // Listener pour le bouton Annuler + cancelButton.setOnClickListener(v -> { + Log.d(TAG, "Dialogue redémarrage: Annulé."); + dialog.dismiss(); // Ferme la dialogue + }); + + // Listener pour le bouton Confirmer + confirmButton.setOnClickListener(v -> { + Log.d(TAG, "Dialogue redémarrage: Confirmé."); + dialog.dismiss(); // Ferme la dialogue + startNewGame(); // Lance une nouvelle partie + }); + + dialog.show(); // Affiche la dialogue } /** - * Démarre une nouvelle partie logiquement et rafraîchit l'UI. - * Réinitialise les stats de la partie en cours via `gameStats.startGame()`. - * Ferme le panneau de statistiques s'il est ouvert. + * Démarre une nouvelle partie. + * 1. Informe `GameStats` que la nouvelle partie commence (pour réinitialiser les stats de partie). + * 2. Crée une nouvelle instance de `Game`. + * 3. Applique le meilleur score global (connu par `GameStats`) à cette nouvelle partie. + * 4. Met l'état du jeu (`currentGameState`) à `PLAYING`. + * 5. Ferme le panneau de statistiques s'il était ouvert. + * 6. Met à jour l'interface utilisateur (plateau, scores) pour refléter la nouvelle partie. */ private void startNewGame() { - if (gameStats == null) gameStats = new GameStats(this); // Précaution si initialisation a échoué avant - gameStats.startGame(); // Réinitialise stats de partie (temps, mouvements, etc.) - game = new Game(); // Crée un nouveau jeu logique - game.setHighestScore(gameStats.getOverallHighScore()); // Applique le meilleur score global au nouveau jeu - currentGameState = GameFlowState.PLAYING; // Définit l'état à JOUER + Log.i(TAG, "startNewGame: Démarrage d'une nouvelle partie."); + if (gameStats == null) { + Log.e(TAG, "startNewGame: GameStats est null! Tentative de réinitialisation."); + gameStats = new GameStats(this); // Précaution + } + // Informe GameStats du début de la partie (reset timers/compteurs de partie) + gameStats.startGame(); + + // Crée une nouvelle instance de la logique du jeu + game = new Game(); + // Applique le meilleur score global connu au nouvel objet Game + game.setHighestScore(gameStats.getOverallHighScore()); + + // Définit l'état du flux de jeu + currentGameState = GameFlowState.PLAYING; // Ferme le panneau de statistiques s'il était ouvert if (statisticsVisible) { + Log.d(TAG, "startNewGame: Fermeture du panneau de statistiques."); toggleStatistics(); // Utilise la méthode existante pour masquer proprement } - syncBoardView(); - - updateUI(); // Met à jour l'affichage (plateau, scores) + // Met à jour l'interface utilisateur pour afficher le nouveau plateau et les scores (0) + updateUI(); // Appellera syncBoardView et updateScores } /** - * Affiche la boîte de dialogue quand 2048 est atteint, en utilisant un layout personnalisé. - * Propose de continuer à jouer ou de commencer une nouvelle partie. + * Affiche la boîte de dialogue signalant que le joueur a gagné (atteint 2048). + * Utilise le layout personnalisé `dialog_game_won.xml`. + * Propose de continuer à jouer ("Keep Playing") ou de commencer une nouvelle partie. + * Joue le son de victoire. */ private void showGameWonKeepPlayingDialog() { - playSound(soundWinId); + Log.i(TAG, "Affichage dialogue de victoire."); + playSound(soundWinId); // Joue le son de victoire + AlertDialog.Builder builder = new AlertDialog.Builder(this); LayoutInflater inflater = getLayoutInflater(); - View dialogView = inflater.inflate(R.layout.dialog_game_won, null); + View dialogView = inflater.inflate(R.layout.dialog_game_won, null); // Gonfle le layout builder.setView(dialogView); - builder.setCancelable(false); + builder.setCancelable(false); // Empêche de fermer sans choisir une option Button keepPlayingButton = dialogView.findViewById(R.id.dialogKeepPlayingButton); - Button newGameButton = dialogView.findViewById(R.id.dialogNewGameButtonWon); + Button newGameButtonDialog = dialogView.findViewById(R.id.dialogNewGameButtonWon); // Renommé pour éviter conflit + final AlertDialog dialog = builder.create(); + // Listener pour "Continuer à jouer" keepPlayingButton.setOnClickListener(v -> { - // L'état est déjà WON_DIALOG_SHOWN, on ne fait rien de spécial, le jeu continue. + Log.d(TAG, "Dialogue victoire: Continué."); + // L'état est déjà WON_DIALOG_SHOWN, le jeu peut continuer si l'utilisateur swipe. + // On ferme juste la dialogue. dialog.dismiss(); }); - newGameButton.setOnClickListener(v -> { + + // Listener pour "Nouvelle Partie" + newGameButtonDialog.setOnClickListener(v -> { + Log.d(TAG, "Dialogue victoire: Nouvelle partie."); dialog.dismiss(); - startNewGame(); + startNewGame(); // Démarre une nouvelle partie }); + dialog.show(); } /** - * Affiche la boîte de dialogue de fin de partie (plus de mouvements), en utilisant un layout personnalisé. - * Propose Nouvelle Partie ou Quitter. + * Affiche la boîte de dialogue signalant la fin de partie (Game Over). + * Utilise le layout personnalisé `dialog_game_over.xml`. + * Affiche le score final et propose de commencer une Nouvelle Partie ou de Quitter l'application. + * Joue le son de fin de partie. */ private void showGameOverDialog() { - playSound(soundGameOverId); + Log.i(TAG, "Affichage dialogue Game Over. Score final: " + (game != null ? game.getCurrentScore() : "N/A")); + playSound(soundGameOverId); // Joue le son de Game Over + AlertDialog.Builder builder = new AlertDialog.Builder(this); LayoutInflater inflater = getLayoutInflater(); - View dialogView = inflater.inflate(R.layout.dialog_game_over, null); + View dialogView = inflater.inflate(R.layout.dialog_game_over, null); // Gonfle le layout builder.setView(dialogView); - builder.setCancelable(false); + builder.setCancelable(false); // Empêche de fermer sans choisir TextView messageTextView = dialogView.findViewById(R.id.dialogMessageGameOver); - Button newGameButton = dialogView.findViewById(R.id.dialogNewGameButtonGameOver); + Button newGameButtonDialog = dialogView.findViewById(R.id.dialogNewGameButtonGameOver); // Renommé Button quitButton = dialogView.findViewById(R.id.dialogQuitButtonGameOver); - messageTextView.setText(getString(R.string.game_over_message, game.getCurrentScore())); + + // Affiche le message avec le score final + if (game != null) { + messageTextView.setText(getString(R.string.game_over_message, game.getCurrentScore())); + } else { + messageTextView.setText(getString(R.string.game_over_title)); // Message générique si game est null + } + final AlertDialog dialog = builder.create(); - newGameButton.setOnClickListener(v -> { + // Listener pour "Nouvelle Partie" + newGameButtonDialog.setOnClickListener(v -> { + Log.d(TAG, "Dialogue Game Over: Nouvelle partie."); dialog.dismiss(); startNewGame(); }); + + // Listener pour "Quitter" quitButton.setOnClickListener(v -> { + Log.d(TAG, "Dialogue Game Over: Quitter."); dialog.dismiss(); - finish(); // Ferme l'application + finish(); // Ferme l'activité actuelle (et l'application si c'est la seule activité) }); + dialog.show(); } // --- Menu Principal --- /** - * Affiche la boîte de dialogue du menu principal en utilisant un layout personnalisé. - * Attache les listeners aux boutons pour déclencher les actions correspondantes. + * Affiche la boîte de dialogue du menu principal. + * Utilise le layout personnalisé `dialog_main_menu.xml`. + * Contient des boutons pour "Comment Jouer", "Paramètres", "À Propos", et "Retour". */ private void showMenu() { + Log.d(TAG, "Affichage dialogue du menu principal."); AlertDialog.Builder builder = new AlertDialog.Builder(this); LayoutInflater inflater = getLayoutInflater(); View dialogView = inflater.inflate(R.layout.dialog_main_menu, null); // Gonfle le layout personnalisé builder.setView(dialogView); - builder.setCancelable(true); + builder.setCancelable(true); // Permet de fermer en cliquant à côté // Récupère les boutons du layout personnalisé Button howToPlayButton = dialogView.findViewById(R.id.menuButtonHowToPlay); @@ -818,34 +1156,39 @@ public class MainActivity extends AppCompatActivity { final AlertDialog dialog = builder.create(); - // Attache les listeners aux boutons + // Attache les listeners aux boutons du menu howToPlayButton.setOnClickListener(v -> { + Log.d(TAG, "Menu: Clic sur Comment Jouer."); dialog.dismiss(); // Ferme le menu showHowToPlayDialog(); // Ouvre la dialogue "Comment Jouer" }); settingsButton.setOnClickListener(v -> { + Log.d(TAG, "Menu: Clic sur Paramètres."); dialog.dismiss(); // Ferme le menu - showSettingsDialog(); // Ouvre la dialogue placeholder "Paramètres" + showSettingsDialog(); // Ouvre la dialogue "Paramètres" }); aboutButton.setOnClickListener(v -> { + Log.d(TAG, "Menu: Clic sur À Propos."); dialog.dismiss(); // Ferme le menu showAboutDialog(); // Ouvre la dialogue "À Propos" }); returnButton.setOnClickListener(v -> { + Log.d(TAG, "Menu: Clic sur Retour."); dialog.dismiss(); // Ferme simplement le menu }); - dialog.show(); // Affiche la boîte de dialogue + dialog.show(); // Affiche la boîte de dialogue du menu } /** - * Affiche une boîte de dialogue expliquant les règles du jeu, - * en utilisant un layout personnalisé pour une meilleure présentation. + * Affiche une boîte de dialogue expliquant les règles du jeu 2048. + * Utilise le layout personnalisé `dialog_how_to_play.xml`. */ private void showHowToPlayDialog() { + Log.d(TAG, "Affichage dialogue Comment Jouer."); AlertDialog.Builder builder = new AlertDialog.Builder(this); LayoutInflater inflater = getLayoutInflater(); View dialogView = inflater.inflate(R.layout.dialog_how_to_play, null); // Gonfle le layout @@ -857,303 +1200,497 @@ public class MainActivity extends AppCompatActivity { final AlertDialog dialog = builder.create(); - okButton.setOnClickListener(v -> dialog.dismiss()); // Ferme simplement + okButton.setOnClickListener(v -> { + Log.d(TAG, "Dialogue Comment Jouer: OK cliqué."); + dialog.dismiss(); // Ferme simplement + }); dialog.show(); } /** - * Affiche la boîte de dialogue des paramètres en utilisant un layout personnalisé. - * Gère les interactions avec les différentes options. + * Affiche la boîte de dialogue des paramètres de l'application. + * Utilise le layout personnalisé `dialog_settings.xml`. + * Permet à l'utilisateur de gérer : + * - Activation/Désactivation des effets sonores. + * - Activation/Désactivation des notifications (avec demande de permission si nécessaire). + * - Accès aux paramètres système de l'application (pour gérer les permissions manuellement). + * - Partage des statistiques. + * - Réinitialisation des statistiques (avec confirmation). + * - Quitter l'application. */ private void showSettingsDialog() { + Log.d(TAG, "Affichage dialogue Paramètres."); AlertDialog.Builder builder = new AlertDialog.Builder(this); LayoutInflater inflater = getLayoutInflater(); - View dialogView = inflater.inflate(R.layout.dialog_settings, null); - builder.setView(dialogView).setCancelable(true); + View dialogView = inflater.inflate(R.layout.dialog_settings, null); // Gonfle le layout + builder.setView(dialogView).setCancelable(true); // Permet de fermer en cliquant à côté - // Vues + // Récupération des vues dans la dialogue SwitchMaterial switchSound = dialogView.findViewById(R.id.switchSound); - SwitchMaterial switchNotifications = dialogView.findViewById(R.id.switchNotifications); // Activé + SwitchMaterial switchNotifications = dialogView.findViewById(R.id.switchNotifications); Button permissionsButton = dialogView.findViewById(R.id.buttonManagePermissions); Button shareStatsButton = dialogView.findViewById(R.id.buttonShareStats); Button resetStatsButton = dialogView.findViewById(R.id.buttonResetStats); Button quitAppButton = dialogView.findViewById(R.id.buttonQuitApp); Button closeButton = dialogView.findViewById(R.id.buttonCloseSettings); - // Ajout boutons de test (optionnel, pour débugger) - Button testNotifHS = new Button(this); // Crée programmatiquement - testNotifHS.setText(R.string.settings_test_notif_highscore); - Button testNotifInactiv = new Button(this); - testNotifInactiv.setText(R.string.settings_test_notif_inactivity); - // Ajouter ces boutons au layout 'dialogView' si nécessaire (ex: ((LinearLayout)dialogView).addView(...) ) + // Les boutons de test ont été retirés car non ajoutés au layout final AlertDialog dialog = builder.create(); - // Config Son (MAINTENANT ACTIF) - switchSound.setEnabled(true); // Activé - switchSound.setChecked(soundEnabled); // État actuel chargé + // --- Configuration du Switch Son --- + switchSound.setChecked(soundEnabled); // Initialise avec l'état actuel switchSound.setOnCheckedChangeListener((buttonView, isChecked) -> { - soundEnabled = isChecked; // Met à jour l'état + Log.d(TAG, "Paramètres: Switch Son changé à " + isChecked); + soundEnabled = isChecked; // Met à jour l'état interne saveSoundPreference(isChecked); // Sauvegarde la préférence Toast.makeText(this, isChecked ? R.string.sound_enabled : R.string.sound_disabled, Toast.LENGTH_SHORT).show(); + // Jouer un petit son de test ici ? Optionnel. }); - // Config Notifications (Activé + Gestion Permission) - switchNotifications.setEnabled(true); // Activé - switchNotifications.setChecked(notificationsEnabled); // État actuel + // --- Configuration du Switch Notifications --- + switchNotifications.setChecked(notificationsEnabled); // Initialise avec l'état actuel switchNotifications.setOnCheckedChangeListener((buttonView, isChecked) -> { + Log.d(TAG, "Paramètres: Switch Notifications changé à " + isChecked); if (isChecked) { // L'utilisateur VEUT activer les notifications - requestNotificationPermission(); // Demande la permission si nécessaire + Log.d(TAG, "Paramètres: Demande d'activation des notifications."); + requestNotificationPermission(); // Demande la permission si nécessaire (gère aussi l'activation si déjà accordée) } else { // L'utilisateur désactive les notifications + Log.d(TAG, "Paramètres: Désactivation des notifications."); notificationsEnabled = false; saveNotificationPreference(false); Toast.makeText(this, R.string.notifications_disabled, Toast.LENGTH_SHORT).show(); - // Ici, annuler les éventuelles notifications planifiées (WorkManager/AlarmManager) + // Arrêter le service de notification si l'utilisateur désactive + stopNotificationService(); + // Annuler les éventuelles notifications planifiées (via WorkManager/AlarmManager si utilisé) } + // Note: L'état 'notificationsEnabled' est mis à jour dans le callback de requestPermissionLauncher si la permission est accordée/refusée. + // Le switch peut se désynchroniser brièvement si l'utilisateur refuse la permission. updateNotificationSwitchState est appelé dans le callback. }); - // Listeners autres boutons (Permissions, Share, Reset, Quit, Close) - permissionsButton.setOnClickListener(v -> { openAppSettings(); dialog.dismiss(); }); - shareStatsButton.setOnClickListener(v -> { shareStats(); dialog.dismiss(); }); - resetStatsButton.setOnClickListener(v -> { dialog.dismiss(); showResetStatsConfirmationDialog(); }); - quitAppButton.setOnClickListener(v -> { dialog.dismiss(); finishAffinity(); }); - closeButton.setOnClickListener(v -> dialog.dismiss()); + // --- Listeners pour les autres boutons --- + // Bouton Gérer Permissions -> Ouvre les paramètres système de l'app + permissionsButton.setOnClickListener(v -> { + Log.d(TAG, "Paramètres: Clic sur Gérer Permissions."); + openAppSettings(); + dialog.dismiss(); // Ferme les paramètres de l'app après avoir ouvert ceux du système + }); - // Listeners boutons de test (si ajoutés) - testNotifHS.setOnClickListener(v -> { showHighScoreNotification(gameStats.getOverallHighScore()); }); - testNotifInactiv.setOnClickListener(v -> { showInactivityNotification(); }); + // Bouton Partager Stats -> Lance une Intent de partage + shareStatsButton.setOnClickListener(v -> { + Log.d(TAG, "Paramètres: Clic sur Partager Stats."); + shareStats(); + dialog.dismiss(); + }); + // Bouton Réinitialiser Stats -> Affiche dialogue de confirmation + resetStatsButton.setOnClickListener(v -> { + Log.d(TAG, "Paramètres: Clic sur Réinitialiser Stats."); + dialog.dismiss(); // Ferme les paramètres AVANT d'ouvrir la confirmation + showResetStatsConfirmationDialog(); + }); + + // Bouton Quitter l'App -> Ferme toutes les activités de l'application + quitAppButton.setOnClickListener(v -> { + Log.d(TAG, "Paramètres: Clic sur Quitter App."); + dialog.dismiss(); + finishAffinity(); // Ferme l'application complètement + }); + + // Bouton Fermer -> Ferme simplement la dialogue des paramètres + closeButton.setOnClickListener(v -> { + Log.d(TAG, "Paramètres: Clic sur Fermer."); + dialog.dismiss(); + }); + + // // Emplacement pour ajouter les listeners des boutons de test si besoin + // testNotifHS.setOnClickListener(v -> { if(gameStats!=null) showHighScoreNotification(gameStats.getOverallHighScore()); }); + // testNotifInactiv.setOnClickListener(v -> { showInactivityNotification(); }); dialog.show(); } - /** Met à jour l'état du switch notification (utile si permission refusée). */ + /** + * Met à jour l'état visuel de l'interrupteur de notifications. + * Actuellement, cette méthode est un placeholder car mettre à jour une vue + * dans une dialogue non affichée ou après sa fermeture est complexe. + * L'approche la plus simple est que l'utilisateur doive rouvrir les paramètres + * pour voir l'état mis à jour si la permission a été refusée. + * + * @param isEnabled L'état souhaité pour l'interrupteur (généralement false si permission refusée). + */ private void updateNotificationSwitchState(boolean isEnabled) { - // Si la vue des paramètres est actuellement affichée, met à jour le switch - View settingsDialogView = getLayoutInflater().inflate(R.layout.dialog_settings, null); // Attention, regonfler n'est pas idéal - // Mieux: Garder une référence à la vue ou au switch si la dialog est affichée. - // Pour la simplicité ici, on suppose qu'il faut rouvrir les paramètres pour voir le changement. + // Idéalement, si la dialogue des paramètres est encore ouverte (AlertDialog dialog), + // on récupérerait le switch et on ferait dialogView.findViewById(R.id.switchNotifications).setChecked(isEnabled); + // Mais gérer la référence à la dialogue/vue de manière fiable est complexe. + Log.d(TAG, "updateNotificationSwitchState: État notif demandé: " + isEnabled + " (Mise à jour visuelle du switch non implémentée si dialogue fermée)"); + // Si l'utilisateur vient de refuser, on s'assure que notre état interne est correct. + if (!isEnabled) { + notificationsEnabled = false; + saveNotificationPreference(false); + } } - /** Sauvegarde la préférence d'activation des notifications. */ + /** + * Sauvegarde la préférence utilisateur pour l'activation/désactivation des notifications + * dans les SharedPreferences. + * + * @param enabled true pour activer, false pour désactiver. + */ private void saveNotificationPreference(boolean enabled) { if (preferences != null) { - preferences.edit().putBoolean("notifications_enabled", enabled).apply(); + Log.d(TAG, "Sauvegarde préférence notifications: " + enabled); + preferences.edit().putBoolean(NOTIFICATIONS_ENABLED_KEY, enabled).apply(); + } else { + Log.e(TAG, "Impossible de sauvegarder préférence notifications, SharedPreferences est null."); } } - /** Charge la préférence d'activation des notifications. */ + /** + * Charge la préférence utilisateur pour l'activation/désactivation des notifications + * depuis les SharedPreferences. Par défaut à false si non définie. + */ private void loadNotificationPreference() { if (preferences != null) { - // Change le défaut de true à false - notificationsEnabled = preferences.getBoolean("notifications_enabled", false); + // Défaut à false - l'utilisateur doit activement les activer. + notificationsEnabled = preferences.getBoolean(NOTIFICATIONS_ENABLED_KEY, false); + Log.d(TAG, "Chargement préférence notifications: " + notificationsEnabled); } else { notificationsEnabled = false; // Assure une valeur par défaut si prefs est null + Log.e(TAG, "Impossible de charger préférence notifications, SharedPreferences est null. Défaut: false."); } } + /** + * Sauvegarde la préférence utilisateur pour l'activation/désactivation des effets sonores + * dans les SharedPreferences. + * + * @param enabled true pour activer, false pour désactiver. + */ + private void saveSoundPreference(boolean enabled) { + if (preferences != null) { + Log.d(TAG, "Sauvegarde préférence son: " + enabled); + preferences.edit().putBoolean(SOUND_ENABLED_KEY, enabled).apply(); + } else { + Log.e(TAG, "Impossible de sauvegarder préférence son, SharedPreferences est null."); + } + } - /** Ouvre les paramètres système de l'application. */ + /** + * Charge la préférence utilisateur pour l'activation/désactivation des effets sonores + * depuis les SharedPreferences. Par défaut à true si non définie. + */ + private void loadSoundPreference() { + if (preferences != null) { + soundEnabled = preferences.getBoolean(SOUND_ENABLED_KEY, true); // Son activé par défaut + Log.d(TAG, "Chargement préférence son: " + soundEnabled); + } else { + soundEnabled = true; // Assure une valeur par défaut si prefs est null + Log.e(TAG, "Impossible de charger préférence son, SharedPreferences est null. Défaut: true."); + } + } + + /** + * Ouvre l'écran des paramètres système spécifiques à cette application, + * permettant à l'utilisateur de gérer manuellement les permissions accordées. + * Utilise une Intent {@code Settings.ACTION_APPLICATION_DETAILS_SETTINGS}. + */ private void openAppSettings() { + Log.d(TAG, "Ouverture des paramètres système de l'application."); Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); + // Crée l'URI pointant vers les détails de notre package Uri uri = Uri.fromParts("package", getPackageName(), null); intent.setData(uri); try { - startActivity(intent); + startActivity(intent); // Lance l'Intent } catch (ActivityNotFoundException e) { + // Gère le cas (très improbable) où l'écran des paramètres ne peut être ouvert + Log.e(TAG, "Impossible d'ouvrir les paramètres système de l'application.", e); Toast.makeText(this, "Impossible d'ouvrir les paramètres.", Toast.LENGTH_LONG).show(); } } // --- Gestion des Permissions (Notifications) --- - /** Demande la permission POST_NOTIFICATIONS si nécessaire (Android 13+). */ + /** + * Demande la permission {@code POST_NOTIFICATIONS} si l'application tourne + * sur Android 13 (API 33) ou supérieur et si la permission n'a pas déjà été accordée. + * Si la permission est déjà accordée ou si l'API est inférieure à 33, active + * directement les notifications (met à jour l'état interne et sauvegarde la préférence). + * Utilise le {@code requestPermissionLauncher} pour gérer la réponse asynchrone. + */ private void requestNotificationPermission() { - // Vérifie si on est sur Android 13+ + // Vérifie si on est sur Android 13 (TIRAMISU) ou plus if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Log.d(TAG, "Vérification permission POST_NOTIFICATIONS sur API 33+"); // Vérifie si la permission n'est PAS déjà accordée - if (ActivityCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { - // Demande la permission + if (ContextCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + // La permission n'est pas accordée, il faut la demander. + Log.i(TAG, "Permission POST_NOTIFICATIONS non accordée. Demande en cours..."); + // Lance la demande de permission via le launcher enregistré requestPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS); // Le résultat sera géré dans le callback du requestPermissionLauncher } else { - // La permission est déjà accordée, on peut activer directement + // La permission est DÉJÀ accordée. + Log.i(TAG, "Permission POST_NOTIFICATIONS déjà accordée."); + // On peut activer directement les notifications (si ce n'est pas déjà fait) + if (!notificationsEnabled) { + notificationsEnabled = true; + saveNotificationPreference(true); + Toast.makeText(this, R.string.notifications_enabled, Toast.LENGTH_SHORT).show(); + startNotificationService(); // Démarrer le service + } + } + } else { + // Sur les versions antérieures à Android 13, pas besoin de demander la permission runtime. + Log.i(TAG, "API < 33, permission POST_NOTIFICATIONS non requise explicitement."); + // On considère la permission comme accordée, on active les notifications. + if (!notificationsEnabled) { notificationsEnabled = true; saveNotificationPreference(true); Toast.makeText(this, R.string.notifications_enabled, Toast.LENGTH_SHORT).show(); - // Planifier les notifications ici si ce n'est pas déjà fait + startNotificationService(); // Démarrer le service } - } else { - // Sur les versions antérieures à Android 13, pas besoin de demander la permission - notificationsEnabled = true; - saveNotificationPreference(true); - Toast.makeText(this, R.string.notifications_enabled, Toast.LENGTH_SHORT).show(); - // Planifier les notifications ici } } - // --- Logique de Notification --- + // --- Logique de Notification (Affichage principalement délégué à NotificationHelper) --- /** - * Crée l'Intent qui sera lancé au clic sur une notification (ouvre MainActivity). - * @return PendingIntent configuré. + * Crée l'Intent qui sera lancé lorsque l'utilisateur clique sur une notification. + * Dans ce cas, l'intent ouvre (ou ramène au premier plan) la {@code MainActivity}. + * Utilise les flags appropriés pour la gestion de la pile d'activités et l'immutabilité. + * + * @return Le {@link PendingIntent} configuré pour être attaché à la notification. */ private PendingIntent createNotificationTapIntent() { Intent intent = new Intent(this, MainActivity.class); - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); // Ouvre l'app ou la ramène devant - // FLAG_IMMUTABLE est requis pour Android 12+ - int flags = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) ? PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE : PendingIntent.FLAG_UPDATE_CURRENT; + // FLAG_ACTIVITY_NEW_TASK: Démarre l'activité dans une nouvelle tâche si nécessaire. + // FLAG_ACTIVITY_CLEAR_TASK: Efface la tâche existante avant de démarrer l'activité (si NEW_TASK est aussi défini). + // Résultat : ramène l'instance existante au premier plan ou en crée une nouvelle. + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + + // FLAG_IMMUTABLE est requis pour Android 12+ (API 31) pour les PendingIntents utilisés par le système. + // FLAG_UPDATE_CURRENT: Si un PendingIntent avec le même requestCode existe déjà, met à jour son Intent extra data. + int flags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { // FLAG_IMMUTABLE existe depuis API 23, mais requis système depuis 31 + flags |= PendingIntent.FLAG_IMMUTABLE; + } return PendingIntent.getActivity(this, 0, intent, flags); } /** - * Construit et affiche une notification simple. - * @param context Contexte. - * @param title Titre de la notification. - * @param message Corps du message de la notification. - * @param notificationId ID unique pour cette notification. + * Construit et affiche une notification simple en utilisant {@link NotificationHelper}. + * Vérifie d'abord si les notifications sont activées et si la permission est accordée. + * + * @param context Contexte (typiquement 'this' depuis l'activité). + * @param title Titre de la notification. + * @param message Corps du message de la notification. + * @param notificationId ID unique pour cette notification (permet de la mettre à jour ou l'annuler). */ private void showNotification(Context context, String title, String message, int notificationId) { - // Vérifie si les notifications sont activées et si la permission est accordée (pour Android 13+) - if (!notificationsEnabled || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && - ActivityCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED)) { - // Ne pas envoyer la notification si désactivé ou permission manquante + // Vérification globale avant d'appeler le Helper + if (!notificationsEnabled) { + Log.w(TAG, "showNotification: Notifications désactivées, notification [" + notificationId + "] non envoyée."); + return; + } + // Le Helper vérifiera aussi la permission pour API 33+ + Log.d(TAG, "showNotification: Tentative d'affichage de la notification ID " + notificationId); + NotificationHelper.showNotification(context, title, message, notificationId); + } + + /** + * Démarre le {@link NotificationService} en arrière-plan s'il n'est pas déjà lancé. + * Ce service est responsable de l'envoi des notifications périodiques (rappel HS, inactivité). + * Ne fait rien si les notifications ne sont pas activées ou si la permission manque. + */ + private void startNotificationService() { + if (!notificationsEnabled) { + Log.w(TAG, "startNotificationService: Notifications désactivées, service non démarré."); + return; + } + // Sur API 33+, revérifier la permission avant de démarrer + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + Log.w(TAG, "startNotificationService: Permission POST_NOTIFICATIONS manquante sur API 33+, service non démarré."); return; } - NotificationCompat.Builder builder = new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID) - .setSmallIcon(R.drawable.ic_stat_notification_2048) // Votre icône - .setContentTitle(title) - .setContentText(message) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setContentIntent(createNotificationTapIntent()) // Action au clic - .setAutoCancel(true); // Ferme la notif après clic - - NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); - // L'ID notificationId est utilisé pour mettre à jour une notif existante ou en afficher une nouvelle - notificationManager.notify(notificationId, builder.build()); - } - - /** Démarre le NotificationService s'il n'est pas déjà lancé. */ - private void startNotificationService() { + Log.i(TAG, "Démarrage du NotificationService."); Intent serviceIntent = new Intent(this, NotificationService.class); - // Utiliser startForegroundService pour API 26+ si le service doit faire qqch rapidement - // mais pour une tâche périodique simple startService suffit. - startService(serviceIntent); + try { + // Utiliser startService. Si le service a besoin d'être foreground (API 26+), + // il doit appeler startForeground() lui-même rapidement. + startService(serviceIntent); + } catch (IllegalStateException e) { + // Peut arriver sur API 26+ si l'app est en arrière-plan et tente de démarrer un service + // sans le passer en foreground rapidement. NotificationService devrait gérer startForeground si besoin. + Log.e(TAG, "Impossible de démarrer NotificationService (peut-être en arrière-plan sur API 26+ ?)", e); + } catch (SecurityException se) { + Log.e(TAG, "Erreur de sécurité au démarrage de NotificationService.", se); + } } - /** Arrête le NotificationService. */ + /** + * Arrête le {@link NotificationService} s'il est en cours d'exécution. + */ private void stopNotificationService() { + Log.i(TAG, "Arrêt du NotificationService."); Intent serviceIntent = new Intent(this, NotificationService.class); stopService(serviceIntent); } - /** Affiche la notification d'accomplissement via le NotificationHelper. */ + /** + * Affiche la notification d'accomplissement (ex: atteinte d'une tuile spécifique) + * via le {@link NotificationHelper}. + * Vérifie si les notifications sont activées avant d'envoyer. + * + * @param tileValue La valeur de la tuile atteinte (ex: 2048). + */ private void showAchievementNotification(int tileValue) { - // Vérifie l'état global avant d'envoyer (au cas où désactivé entre-temps) - if (!notificationsEnabled) return; + if (!notificationsEnabled) return; // Vérification globale + Log.i(TAG, "Affichage notification d'accomplissement pour la tuile " + tileValue); String title = getString(R.string.notification_title_achievement); String message = getString(R.string.notification_text_achievement, tileValue); - NotificationHelper.showNotification(this, title, message, 1); // ID 1 pour achievement - } - - /** Affiche la notification de rappel du meilleur score (pour test). */ - private void showHighScoreNotification(int highScore) { - String title = getString(R.string.notification_title_highscore); - String message = getString(R.string.notification_text_highscore, highScore); - // Utiliser un ID spécifique (ex: 2) - showNotification(this, title, message, 2); - // NOTE: La planification réelle utiliserait WorkManager/AlarmManager - } - - /** Affiche la notification de rappel d'inactivité (pour test). */ - private void showInactivityNotification() { - String title = getString(R.string.notification_title_inactivity); - String message = getString(R.string.notification_text_inactivity); - // Utiliser un ID spécifique (ex: 3) - showNotification(this, title, message, 3); - // NOTE: La planification réelle utiliserait WorkManager/AlarmManager et suivi du temps + // Utilise ID 1 (par convention définie dans NotificationService ou ici) + showNotification(this, title, message, 1); } /** - * Crée et lance une Intent pour partager les statistiques du joueur. + * Affiche la notification de rappel du meilleur score (utilisé pour test/démo). + * La planification réelle devrait être gérée par {@link NotificationService} ou WorkManager. + * + * @param highScore Le meilleur score actuel à afficher. + */ + private void showHighScoreNotification(int highScore) { + Log.d(TAG, "Affichage notification de test High Score: " + highScore); + String title = getString(R.string.notification_title_highscore); + String message = getString(R.string.notification_text_highscore, highScore); + // Utilise ID 2 (par convention définie dans NotificationService) + showNotification(this, title, message, 2); + } + + /** + * Affiche la notification de rappel d'inactivité (utilisé pour test/démo). + * La planification et la logique réelle devraient être dans {@link NotificationService} ou WorkManager. + */ + private void showInactivityNotification() { + Log.d(TAG, "Affichage notification de test Inactivité."); + String title = getString(R.string.notification_title_inactivity); + String message = getString(R.string.notification_text_inactivity); + // Utilise ID 3 (par convention définie dans NotificationService) + showNotification(this, title, message, 3); + } + + /** + * Crée et lance une Intent {@code Intent.ACTION_SEND} pour permettre à l'utilisateur + * de partager ses statistiques de jeu via d'autres applications (email, réseaux sociaux, etc.). + * Formate un message texte contenant les statistiques clés. */ private void shareStats() { - if (gameStats == null) return; + if (gameStats == null) { + Log.w(TAG, "shareStats: Impossible de partager, GameStats est null."); + Toast.makeText(this, "Statistiques non disponibles.", Toast.LENGTH_SHORT).show(); + return; + } + Log.i(TAG, "Préparation du partage des statistiques."); - // Construit le message à partager + // Construit le message texte à partager String shareBody = getString(R.string.share_stats_body, gameStats.getOverallHighScore(), gameStats.getHighestTile(), gameStats.getNumberOfTimesObjectiveReached(), - gameStats.getTotalGamesStarted(), // Ou totalGamesPlayed ? + gameStats.getTotalGamesStarted(), // Ou totalGamesPlayed ? Utilisons started pour cohérence avec win % GameStats.formatTime(gameStats.getTotalPlayTimeMs()), gameStats.getTotalMoves() ); + // Crée l'Intent de partage Intent shareIntent = new Intent(Intent.ACTION_SEND); - shareIntent.setType("text/plain"); - shareIntent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.share_stats_subject)); - shareIntent.putExtra(Intent.EXTRA_TEXT, shareBody); + shareIntent.setType("text/plain"); // Type MIME pour du texte simple + shareIntent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.share_stats_subject)); // Sujet (pour email, etc.) + shareIntent.putExtra(Intent.EXTRA_TEXT, shareBody); // Corps du message + // Lance le sélecteur d'applications pour le partage try { startActivity(Intent.createChooser(shareIntent, getString(R.string.share_stats_title))); } catch (ActivityNotFoundException e) { + // Gère le cas où aucune application ne peut gérer l'Intent de partage + Log.e(TAG, "Aucune application de partage disponible.", e); Toast.makeText(this, "Aucune application de partage disponible.", Toast.LENGTH_SHORT).show(); } } /** - * Affiche une boîte de dialogue pour confirmer la réinitialisation des statistiques. + * Affiche une boîte de dialogue de confirmation avant de réinitialiser + * toutes les statistiques du joueur. Prévient l'utilisateur que l'action est irréversible. */ private void showResetStatsConfirmationDialog() { + Log.w(TAG, "Affichage dialogue confirmation réinitialisation stats."); new AlertDialog.Builder(this) .setTitle(R.string.reset_stats_confirm_title) .setMessage(R.string.reset_stats_confirm_message) + .setIcon(android.R.drawable.ic_dialog_alert) // Icône d'avertissement standard + // Bouton Positif (Confirmer) .setPositiveButton(R.string.confirm, (dialog, which) -> { + Log.i(TAG, "Réinitialisation des statistiques confirmée."); resetStatistics(); // Appelle la méthode de réinitialisation dialog.dismiss(); }) - .setNegativeButton(R.string.cancel, (dialog, which) -> dialog.dismiss()) - .setIcon(android.R.drawable.ic_dialog_alert) // Icône d'avertissement + // Bouton Négatif (Annuler) + .setNegativeButton(R.string.cancel, (dialog, which) -> { + Log.d(TAG, "Réinitialisation des statistiques annulée."); + dialog.dismiss(); + }) + .setCancelable(false) // Empêche de fermer sans choisir .show(); } /** - * Réinitialise toutes les statistiques via GameStats et sauvegarde les changements. - * Affiche une confirmation à l'utilisateur. + * Réinitialise toutes les statistiques stockées via l'objet {@link GameStats}. + * Met également à jour le meilleur score affiché dans l'interface si une partie est en cours. + * Affiche une confirmation à l'utilisateur via un Toast. */ private void resetStatistics() { + Log.i(TAG, "resetStatistics: Réinitialisation des statistiques demandée."); if (gameStats != null) { - gameStats.resetStats(); // Réinitialise les stats dans l'objet - gameStats.saveStats(); // Sauvegarde les stats réinitialisées - // Met aussi à jour le highScore de l'objet Game courant (si une partie est en cours) + gameStats.resetStats(); // Réinitialise les stats dans l'objet et sauvegarde via SharedPreferences + + // Met aussi à jour le highScore de l'objet Game courant (qui est maintenant 0) if(game != null){ - game.setHighestScore(gameStats.getOverallHighScore()); // Le HS est aussi reset dans GameStats - updateScores(); // Rafraichit l'affichage du HS si visible + game.setHighestScore(gameStats.getOverallHighScore()); // Met à jour l'instance Game + updateScores(); // Rafraîchit l'affichage du HS si visible } + Toast.makeText(this, R.string.stats_reset_confirmation, Toast.LENGTH_SHORT).show(); - // Si les stats étaient visibles, on pourrait vouloir les rafraîchir + + // Si les statistiques étaient visibles, on les rafraîchit pour montrer les zéros if (statisticsVisible && inflatedStatsView != null) { + Log.d(TAG, "resetStatistics: Rafraîchissement du panneau de statistiques."); updateStatisticsTextViews(); } + } else { + Log.e(TAG, "resetStatistics: GameStats est null, impossible de réinitialiser."); } } /** - * Affiche la boîte de dialogue "À Propos" en utilisant un layout personnalisé, - * incluant des informations sur l'application et un lien cliquable vers le site web. + * Affiche la boîte de dialogue "À Propos". + * Utilise le layout personnalisé `dialog_about.xml`. + * Affiche des informations sur l'application (version, développeur) et + * inclut un lien cliquable vers un site web. */ private void showAboutDialog() { + Log.d(TAG, "Affichage dialogue À Propos."); AlertDialog.Builder builder = new AlertDialog.Builder(this); LayoutInflater inflater = getLayoutInflater(); View dialogView = inflater.inflate(R.layout.dialog_about, null); // Gonfle le layout builder.setView(dialogView); - builder.setCancelable(true); // Permet de fermer + builder.setCancelable(true); // Permet de fermer en cliquant à côté // Récupère les vues du layout TextView websiteLinkTextView = dialogView.findViewById(R.id.websiteLinkTextView); @@ -1161,19 +1698,25 @@ public class MainActivity extends AppCompatActivity { final AlertDialog dialog = builder.create(); - // Rend le lien cliquable pour ouvrir le navigateur + // Rend le lien du site web cliquable pour ouvrir le navigateur websiteLinkTextView.setOnClickListener(v -> { - Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.about_website_url))); + String url = getString(R.string.about_website_url); + Log.d(TAG, "Dialogue À Propos: Clic sur le lien du site web -> " + url); + Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); try { startActivity(browserIntent); } catch (ActivityNotFoundException e) { // Gère le cas où aucun navigateur n'est installé + Log.e(TAG, "Aucun navigateur web trouvé pour ouvrir l'URL.", e); Toast.makeText(this, "Aucun navigateur web trouvé.", Toast.LENGTH_SHORT).show(); } }); // Bouton OK pour fermer la dialogue - okButton.setOnClickListener(v -> dialog.dismiss()); + okButton.setOnClickListener(v -> { + Log.d(TAG, "Dialogue À Propos: OK cliqué."); + dialog.dismiss(); + }); dialog.show(); } @@ -1182,37 +1725,67 @@ public class MainActivity extends AppCompatActivity { // --- Gestion Stats UI --- /** - * Affiche ou masque le panneau de statistiques. - * Gonfle le layout via ViewStub si c'est la première fois. + * Affiche ou masque le panneau des statistiques. + * Utilise un {@link ViewStub} pour gonfler le layout (`stats_layout.xml`) uniquement + * lors de la première demande d'affichage (optimisation des performances). + * Gère la visibilité du panneau et du bouton multijoueur. */ private void toggleStatistics() { - statisticsVisible = !statisticsVisible; + statisticsVisible = !statisticsVisible; // Inverse l'état de visibilité + Log.d(TAG, "toggleStatistics: Visibilité changée à " + statisticsVisible); + if (statisticsVisible) { - if (inflatedStatsView == null) { // Gonfle si pas encore fait - inflatedStatsView = statisticsViewStub.inflate(); - // Attache listener au bouton Back une fois la vue gonflée - Button backButton = inflatedStatsView.findViewById(R.id.backButton); - backButton.setOnClickListener(v -> toggleStatistics()); // Recliquer sur Back re-appelle toggle + // Si on veut afficher les stats + if (inflatedStatsView == null) { // Gonfle le layout si ce n'est pas déjà fait + Log.i(TAG, "toggleStatistics: Gonflage du ViewStub des statistiques."); + try { + inflatedStatsView = statisticsViewStub.inflate(); // Gonfle le layout défini dans android:layout="@layout/stats_layout" + // Attache listener au bouton Retour DANS le layout des stats, une fois gonflé + Button backButton = inflatedStatsView.findViewById(R.id.backButton); + if (backButton != null) { + backButton.setOnClickListener(v -> toggleStatistics()); // Recliquer sur Back appelle à nouveau toggle + } else { + Log.e(TAG, "toggleStatistics: Bouton Retour (R.id.backButton) non trouvé dans stats_layout !"); + } + } catch (Exception e) { + Log.e(TAG, "Erreur lors du gonflage du ViewStub des statistiques", e); + statisticsVisible = false; // Annule le changement d'état si erreur + Toast.makeText(this, "Erreur lors de l'affichage des stats.", Toast.LENGTH_SHORT).show(); + return; + } } - updateStatisticsTextViews(); // Remplit les champs avec les données actuelles - inflatedStatsView.setVisibility(View.VISIBLE); // Affiche - multiplayerButton.setVisibility(View.GONE); // Masque bouton multi + // Assure que la vue est visible et met à jour les données + if (inflatedStatsView != null) { + updateStatisticsTextViews(); // Remplit les champs avec les données actuelles + inflatedStatsView.setVisibility(View.VISIBLE); // Affiche le panneau + } + multiplayerButton.setVisibility(View.GONE); // Masque le bouton multijoueur quand les stats sont visibles + } else { - if (inflatedStatsView != null) { // Masque si la vue existe + // Si on veut masquer les stats + if (inflatedStatsView != null) { // Masque le panneau s'il existe inflatedStatsView.setVisibility(View.GONE); } - multiplayerButton.setVisibility(View.VISIBLE); // Réaffiche bouton multi + multiplayerButton.setVisibility(View.VISIBLE); // Réaffiche le bouton multijoueur } } /** - * Remplit les TextViews du panneau de statistiques avec les données de l'objet GameStats. - * Doit être appelé seulement après que `inflatedStatsView` a été initialisé. + * Remplit les {@link TextView} du panneau de statistiques (contenu dans `inflatedStatsView`) + * avec les données actuelles provenant de l'objet {@link GameStats}. + * Doit être appelé seulement après que `inflatedStatsView` a été initialisé (gonflé) + * et seulement si `gameStats` n'est pas null. */ + @SuppressLint("StringFormatInvalid") // Pour R.string.average_time_per_game_label potentiellement mal utilisé private void updateStatisticsTextViews() { - if (inflatedStatsView == null || gameStats == null) return; + if (inflatedStatsView == null || gameStats == null) { + Log.w(TAG, "updateStatisticsTextViews: Vue des stats non gonflée ou GameStats null."); + return; // Ne rien faire si la vue n'existe pas ou les stats ne sont pas prêtes + } + Log.d(TAG, "updateStatisticsTextViews: Mise à jour des labels de statistiques."); - // Récupération des TextViews dans la vue gonflée + // Récupération des TextViews DANS la vue gonflée + // Utiliser findViewByID sur inflatedStatsView, pas sur l'activité ! TextView highScoreStatsLabel = inflatedStatsView.findViewById(R.id.high_score_stats_label); TextView totalGamesPlayedLabel = inflatedStatsView.findViewById(R.id.total_games_played_label); TextView totalGamesStartedLabel = inflatedStatsView.findViewById(R.id.total_games_started_label); @@ -1227,120 +1800,227 @@ public class MainActivity extends AppCompatActivity { TextView totalMergesLabel = inflatedStatsView.findViewById(R.id.total_merges_label); TextView highestTileLabel = inflatedStatsView.findViewById(R.id.highest_tile_label); TextView objectiveReachedLabel = inflatedStatsView.findViewById(R.id.number_of_time_objective_reached_label); - TextView perfectGameLabel = inflatedStatsView.findViewById(R.id.perfect_game_label); + // TextView perfectGameLabel = inflatedStatsView.findViewById(R.id.perfect_game_label); // Supprimé + TextView mergesThisGameLabel = inflatedStatsView.findViewById(R.id.merges_this_game); + + // Multiplayer stats TextViews TextView multiplayerGamesWonLabel = inflatedStatsView.findViewById(R.id.multiplayer_games_won_label); TextView multiplayerGamesPlayedLabel = inflatedStatsView.findViewById(R.id.multiplayer_games_played_label); TextView multiplayerWinRateLabel = inflatedStatsView.findViewById(R.id.multiplayer_win_rate_label); TextView multiplayerBestWinningStreakLabel = inflatedStatsView.findViewById(R.id.multiplayer_best_winning_streak_label); TextView multiplayerAverageScoreLabel = inflatedStatsView.findViewById(R.id.multiplayer_average_score_label); - TextView averageTimePerGameMultiLabel = inflatedStatsView.findViewById(R.id.average_time_per_game_label); // Potentiel ID dupliqué dans layout? + TextView averageTimePerGameMultiLabel = inflatedStatsView.findViewById(R.id.average_time_per_game_label); // *** ATTENTION: ID POTENTIELLEMENT DUPLIQUÉ DANS LE LAYOUT XML *** TextView totalMultiplayerLossesLabel = inflatedStatsView.findViewById(R.id.total_multiplayer_losses_label); TextView multiplayerHighScoreLabel = inflatedStatsView.findViewById(R.id.multiplayer_high_score_label); - TextView mergesThisGameLabel = inflatedStatsView.findViewById(R.id.merges_this_game); - // MAJ textes avec getters de gameStats - highScoreStatsLabel.setText(getString(R.string.high_score_stats, gameStats.getOverallHighScore())); - totalGamesPlayedLabel.setText(getString(R.string.total_games_played, gameStats.getTotalGamesPlayed())); - totalGamesStartedLabel.setText(getString(R.string.total_games_started, gameStats.getTotalGamesStarted())); - totalMovesLabel.setText(getString(R.string.total_moves, gameStats.getTotalMoves())); - currentMovesLabel.setText(getString(R.string.current_moves, gameStats.getCurrentMoves())); - mergesThisGameLabel.setText(getString(R.string.merges_this_game_label, gameStats.getMergesThisGame())); - totalMergesLabel.setText(getString(R.string.total_merges, gameStats.getTotalMerges())); - highestTileLabel.setText(getString(R.string.highest_tile, gameStats.getHighestTile())); - objectiveReachedLabel.setText(getString(R.string.number_of_time_objective_reached, gameStats.getNumberOfTimesObjectiveReached())); - perfectGameLabel.setText(getString(R.string.perfect_games, gameStats.getPerfectGames())); - multiplayerGamesWonLabel.setText(getString(R.string.multiplayer_games_won, gameStats.getMultiplayerGamesWon())); - multiplayerGamesPlayedLabel.setText(getString(R.string.multiplayer_games_played, gameStats.getMultiplayerGamesPlayed())); - multiplayerBestWinningStreakLabel.setText(getString(R.string.multiplayer_best_winning_streak, gameStats.getMultiplayerBestWinningStreak())); - multiplayerAverageScoreLabel.setText(getString(R.string.multiplayer_average_score, gameStats.getMultiplayerAverageScore())); - totalMultiplayerLossesLabel.setText(getString(R.string.total_multiplayer_losses, gameStats.getTotalMultiplayerLosses())); - multiplayerHighScoreLabel.setText(getString(R.string.multiplayer_high_score, gameStats.getMultiplayerHighestScore())); - // Calculs Pourcentages - String winPercentage = (gameStats.getTotalGamesStarted() > 0) ? String.format("%.2f%%", ((double) gameStats.getNumberOfTimesObjectiveReached() / gameStats.getTotalGamesStarted()) * 100) : "N/A"; - winPercentageLabel.setText(getString(R.string.win_percentage, winPercentage)); - String multiplayerWinRate = (gameStats.getMultiplayerGamesPlayed() > 0) ? String.format("%.2f%%", ((double) gameStats.getMultiplayerGamesWon() / gameStats.getMultiplayerGamesPlayed()) * 100) : "N/A"; - multiplayerWinRateLabel.setText(getString(R.string.multiplayer_win_rate, multiplayerWinRate)); + // --- Vérifier la nullité de chaque TextView avant de l'utiliser --- + // (Alternativement, utiliser un View Binding ou Data Binding pour plus de sécurité) - // Calculs Temps - totalPlayTimeLabel.setText(getString(R.string.total_play_time, GameStats.formatTime(gameStats.getTotalPlayTimeMs()))); - // Calcule le temps de la partie en cours seulement si elle n'est pas finie - long currentDurationMs = 0; - if (game != null && gameStats != null && currentGameState == GameFlowState.PLAYING && gameStats.getCurrentGameStartTimeMs() > 0) { - currentDurationMs = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs(); + // Mise à jour des textes avec les getters de gameStats + formatage + if (highScoreStatsLabel != null) highScoreStatsLabel.setText(getString(R.string.high_score_stats, gameStats.getOverallHighScore())); + if (totalGamesPlayedLabel != null) totalGamesPlayedLabel.setText(getString(R.string.total_games_played, gameStats.getTotalGamesPlayed())); + if (totalGamesStartedLabel != null) totalGamesStartedLabel.setText(getString(R.string.total_games_started, gameStats.getTotalGamesStarted())); + if (totalMovesLabel != null) totalMovesLabel.setText(getString(R.string.total_moves, gameStats.getTotalMoves())); + if (currentMovesLabel != null) currentMovesLabel.setText(getString(R.string.current_moves, gameStats.getCurrentMoves())); + if (mergesThisGameLabel != null) mergesThisGameLabel.setText(getString(R.string.merges_this_game_label, gameStats.getMergesThisGame())); + if (totalMergesLabel != null) totalMergesLabel.setText(getString(R.string.total_merges, gameStats.getTotalMerges())); + if (highestTileLabel != null) highestTileLabel.setText(getString(R.string.highest_tile, gameStats.getHighestTile())); + if (objectiveReachedLabel != null) objectiveReachedLabel.setText(getString(R.string.number_of_time_objective_reached, gameStats.getNumberOfTimesObjectiveReached())); + // if (perfectGameLabel != null) perfectGameLabel.setText(getString(R.string.perfect_games, gameStats.getPerfectGames())); // Supprimé + + // Calculs Pourcentages (avec vérification division par zéro) + if (winPercentageLabel != null) { + String winPercentage = (gameStats.getTotalGamesStarted() > 0) + ? String.format(java.util.Locale.US, "%.1f%%", ((double) gameStats.getNumberOfTimesObjectiveReached() / gameStats.getTotalGamesStarted()) * 100) + : "N/A"; + winPercentageLabel.setText(getString(R.string.win_percentage, winPercentage)); + } + if (multiplayerWinRateLabel != null) { + String multiplayerWinRate = (gameStats.getMultiplayerGamesPlayed() > 0) + ? String.format(java.util.Locale.US, "%.1f%%", ((double) gameStats.getMultiplayerGamesWon() / gameStats.getMultiplayerGamesPlayed()) * 100) + : "N/A"; + multiplayerWinRateLabel.setText(getString(R.string.multiplayer_win_rate, multiplayerWinRate)); + } + + // Calculs et formatage des Temps + if (totalPlayTimeLabel != null) totalPlayTimeLabel.setText(getString(R.string.total_play_time, GameStats.formatTime(gameStats.getTotalPlayTimeMs()))); + + // Calcule le temps de la partie en cours seulement si elle est active + if (currentGameTimeLabel != null) { + long currentDurationMs = 0; + if (game != null && currentGameState == GameFlowState.PLAYING && gameStats.getCurrentGameStartTimeMs() > 0) { + currentDurationMs = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs(); + } + currentGameTimeLabel.setText(getString(R.string.current_game_time, GameStats.formatTime(currentDurationMs))); + } + + if (averageGameTimeLabel != null) averageGameTimeLabel.setText(getString(R.string.average_time_per_game, GameStats.formatTime(gameStats.getAverageGameTimeMs()))); + if (bestWinningTimeLabel != null) bestWinningTimeLabel.setText(getString(R.string.best_winning_time, (gameStats.getBestWinningTimeMs() != Long.MAX_VALUE) ? GameStats.formatTime(gameStats.getBestWinningTimeMs()) : "N/A")); + if (worstWinningTimeLabel != null) worstWinningTimeLabel.setText(getString(R.string.worst_winning_time, (gameStats.getWorstWinningTimeMs() != 0) ? GameStats.formatTime(gameStats.getWorstWinningTimeMs()) : "N/A")); + + // MAJ Stats Multi + if (multiplayerGamesWonLabel != null) multiplayerGamesWonLabel.setText(getString(R.string.multiplayer_games_won, gameStats.getMultiplayerGamesWon())); + if (multiplayerGamesPlayedLabel != null) multiplayerGamesPlayedLabel.setText(getString(R.string.multiplayer_games_played, gameStats.getMultiplayerGamesPlayed())); + if (multiplayerBestWinningStreakLabel != null) multiplayerBestWinningStreakLabel.setText(getString(R.string.multiplayer_best_winning_streak, gameStats.getMultiplayerBestWinningStreak())); + if (multiplayerAverageScoreLabel != null) multiplayerAverageScoreLabel.setText(getString(R.string.multiplayer_average_score, gameStats.getMultiplayerAverageScore())); + if (totalMultiplayerLossesLabel != null) totalMultiplayerLossesLabel.setText(getString(R.string.total_multiplayer_losses, gameStats.getTotalMultiplayerLosses())); + if (multiplayerHighScoreLabel != null) multiplayerHighScoreLabel.setText(getString(R.string.multiplayer_high_score, gameStats.getMultiplayerHighestScore())); + + // *** ATTENTION : ID potentiellement dupliqué *** + // L'ID R.id.average_time_per_game_label semble être utilisé pour averageGameTimeLabel ET averageTimePerGameMultiLabel. + // Cela signifie qu'une seule des deux TextViews (probablement la dernière dans le XML) sera trouvée et mise à jour. + // Vous DEVEZ donner des IDs uniques à ces deux TextViews dans stats_layout.xml et mettre à jour les findViewById ici. + if (averageTimePerGameMultiLabel != null) { + // Cette ligne mettra probablement à jour la même TextView que averageGameTimeLabel si l'ID est dupliqué ! + // Assurez-vous que R.id.average_time_per_game_label pointe vers la bonne TextView pour le multi. + try { + averageTimePerGameMultiLabel.setText(getString(R.string.average_time_per_game_label, GameStats.formatTime(gameStats.getMultiplayerAverageTimeMs()))); + } catch (Exception e) { + // Le format de la string R.string.average_time_per_game_label attend peut-être un autre type d'argument + Log.e(TAG, "Erreur de formatage pour average_time_per_game_label (multi). Vérifier la string resource.", e); + // Afficher une valeur par défaut ou un message d'erreur + averageTimePerGameMultiLabel.setText(R.string.average_time_per_game_label); // Affiche le label brut + } + } else { + Log.w(TAG, "TextView pour R.id.average_time_per_game_label (multi) non trouvée."); } - currentGameTimeLabel.setText(getString(R.string.current_game_time, GameStats.formatTime(currentDurationMs))); - averageGameTimeLabel.setText(getString(R.string.average_time_per_game, GameStats.formatTime(gameStats.getAverageGameTimeMs()))); - averageTimePerGameMultiLabel.setText(getString(R.string.average_time_per_game_label, GameStats.formatTime(gameStats.getMultiplayerAverageTimeMs()))); // Assurez-vous que l'ID R.string.average_time_per_game_label est correct - bestWinningTimeLabel.setText(getString(R.string.best_winning_time, (gameStats.getBestWinningTimeMs() != Long.MAX_VALUE) ? GameStats.formatTime(gameStats.getBestWinningTimeMs()) : "N/A")); - worstWinningTimeLabel.setText(getString(R.string.worst_winning_time, (gameStats.getWorstWinningTimeMs() != 0) ? GameStats.formatTime(gameStats.getWorstWinningTimeMs()) : "N/A")); } // --- Placeholders Multi --- - /** Affiche un dialogue placeholder pour le multijoueur. */ + /** + * Affiche une simple boîte de dialogue indiquant que la fonctionnalité multijoueur + * n'est pas encore disponible. Sert de placeholder pour le bouton "Multijoueur". + */ private void showMultiplayerScreen() { + Log.d(TAG, "Affichage dialogue placeholder Multijoueur."); AlertDialog.Builder builder = new AlertDialog.Builder(this); - builder.setTitle("Multijoueur").setMessage("Fonctionnalité multijoueur à venir !").setPositiveButton("OK", null); + builder.setTitle(R.string.multiplayer) + .setMessage("Fonctionnalité multijoueur à venir !") + .setPositiveButton(R.string.ok, null); // Bouton OK qui ferme simplement builder.create().show(); } // --- Sauvegarde / Chargement --- - /** Sauvegarde l'état du jeu et le meilleur score via SharedPreferences. */ + /** + * Sauvegarde l'état essentiel du jeu actuel (plateau + score courant via {@code game.toString()}) + * et le meilleur score global (depuis `game.getHighestScore()`) dans les SharedPreferences. + * Utilise `apply()` pour une sauvegarde asynchrone. + * Appelée typiquement dans `onPause`. + */ private void saveGame() { + if (preferences == null) { + Log.e(TAG, "saveGame: SharedPreferences non initialisé, sauvegarde annulée."); + return; + } SharedPreferences.Editor editor = preferences.edit(); if (game != null) { - editor.putString(GAME_STATE_KEY, game.toString()); // Sérialise Game (plateau + score courant) - // Le meilleur score est géré et sauvegardé par GameStats, mais on le sauve aussi ici pour la synchro au chargement + Log.d(TAG, "saveGame: Sauvegarde de l'état du jeu et du high score."); + editor.putString(GAME_STATE_KEY, game.toString()); // Sérialise l'objet Game + // Le meilleur score est aussi géré par GameStats, mais on le sauve ici aussi + // pour assurer la cohérence lors du chargement direct de l'objet Game. editor.putInt(HIGH_SCORE_KEY, game.getHighestScore()); } else { - editor.remove(GAME_STATE_KEY); // Optionnel: nettoyer si pas de jeu + Log.w(TAG, "saveGame: Instance 'game' est null, suppression de l'état sauvegardé."); + // Optionnel: nettoyer la clé si pas de jeu à sauvegarder + editor.remove(GAME_STATE_KEY); } - editor.apply(); // Utilise apply() pour une sauvegarde asynchrone + editor.apply(); // Sauvegarde asynchrone } - /** Charge l'état du jeu depuis SharedPreferences et synchronise le meilleur score. */ + /** + * Charge l'état du jeu depuis les SharedPreferences. + * 1. Tente de lire la chaîne sérialisée de l'état du jeu (`GAME_STATE_KEY`). + * 2. Charge le meilleur score global (`HIGH_SCORE_KEY`) depuis les préférences (cette valeur + * a aussi été chargée par `gameStats`, on s'assure de la cohérence). + * 3. Tente de désérialiser la chaîne d'état en un objet `Game`. + * 4. Si la désérialisation réussit : + * a. Assigne l'objet chargé à la variable `game`. + * b. Applique le meilleur score chargé à l'objet `game`. + * c. Détermine l'état `currentGameState` (PLAYING, WON_DIALOG_SHOWN, GAME_OVER) basé sur le jeu chargé. + * 5. Si la désérialisation échoue ou si aucune sauvegarde n'existe, `game` reste `null` (ou devient `null`), + * ce qui déclenchera la création d'une nouvelle partie dans `initializeGameAndStats`. + */ private void loadGame() { - String gameStateString = preferences.getString(GAME_STATE_KEY, null); - // Charge le meilleur score depuis les préférences (sera aussi chargé par GameStats mais on l'utilise ici pour Game) - int savedHighScore = preferences.getInt(HIGH_SCORE_KEY, 0); - - // Assure que GameStats charge son état (y compris le HS global) - if (gameStats == null) { gameStats = new GameStats(this); } // Précaution - gameStats.loadStats(); // Charge explicitement les stats (ce qui devrait inclure le HS global) - // S'assure que le HS chargé par gameStats est cohérent avec celui des prefs directes - if (savedHighScore > gameStats.getOverallHighScore()) { - gameStats.setHighestScore(savedHighScore); // Assure que GameStats a au moins le HS trouvé ici - } else { - savedHighScore = gameStats.getOverallHighScore(); // Utilise le HS de GameStats s'il est plus grand + if (preferences == null) { + Log.e(TAG, "loadGame: SharedPreferences non initialisé, chargement annulé."); + game = null; + return; } + Log.d(TAG, "loadGame: Tentative de chargement de l'état du jeu."); + String gameStateString = preferences.getString(GAME_STATE_KEY, null); + + // Charge le meilleur score depuis les préférences directes. + int loadedHighScore = preferences.getInt(HIGH_SCORE_KEY, 0); + Log.d(TAG, "loadGame: High score chargé depuis les prefs directes: " + loadedHighScore); + + // Assure que GameStats (qui a aussi chargé les prefs) est la source de vérité pour le HS + if (gameStats == null) { + Log.w(TAG, "loadGame: GameStats est null lors du chargement! Tentative d'init."); + gameStats = new GameStats(this); // Devrait déjà être initialisé, mais sécurité + } + // Prend le max entre le HS chargé directement et celui chargé par GameStats + int authoritativeHighScore = Math.max(loadedHighScore, gameStats.getOverallHighScore()); + // S'assure que GameStats a bien la valeur la plus élevée + if (authoritativeHighScore > gameStats.getOverallHighScore()) { + gameStats.setHighestScore(authoritativeHighScore); + // Pas besoin de saveStats() ici, sera fait dans onPause. + } + Log.d(TAG, "loadGame: High score définitif après synchro avec GameStats: " + authoritativeHighScore); Game loadedGame = null; if (gameStateString != null) { + Log.d(TAG, "loadGame: État de jeu trouvé dans les prefs, tentative de désérialisation."); loadedGame = Game.deserialize(gameStateString); + } else { + Log.d(TAG, "loadGame: Aucun état de jeu trouvé dans les prefs."); } + // Si un jeu a été correctement désérialisé if (loadedGame != null) { + Log.i(TAG, "loadGame: Désérialisation réussie."); game = loadedGame; - game.setHighestScore(savedHighScore); // Applique le HS synchronisé - // Détermine l'état basé sur le jeu chargé + game.setHighestScore(authoritativeHighScore); // Applique le HS synchronisé + + // Détermine l'état de jeu basé sur le plateau chargé if (game.isGameOver()) { + Log.d(TAG, "loadGame: État chargé -> GAME_OVER"); currentGameState = GameFlowState.GAME_OVER; } else if (game.isGameWon()) { - // Si on charge une partie déjà gagnée, on considère qu'on a déjà vu la dialog + // Si on charge une partie déjà gagnée, on considère qu'on a déjà vu la dialogue + Log.d(TAG, "loadGame: État chargé -> WON_DIALOG_SHOWN (partie déjà gagnée)"); currentGameState = GameFlowState.WON_DIALOG_SHOWN; } else { + Log.d(TAG, "loadGame: État chargé -> PLAYING"); currentGameState = GameFlowState.PLAYING; // Le timer sera (re)démarré dans onResume si l'état est PLAYING } } else { - // Pas de sauvegarde valide ou erreur de désérialisation -> Commence une nouvelle partie implicitement - game = null; // Sera géré par l'appel à startNewGame dans initializeGameAndStats + // Pas de sauvegarde valide ou erreur de désérialisation + Log.w(TAG, "loadGame: Échec de la désérialisation ou pas de sauvegarde. 'game' mis à null."); + game = null; // Force à null pour déclencher startNewGame() dans initializeGameAndStats + currentGameState = GameFlowState.PLAYING; // Nouvel état par défaut } } -} // Fin MainActivity \ No newline at end of file + /** + * Sauvegarde le timestamp actuel dans les SharedPreferences. + * Utilisé par {@link NotificationService} pour déterminer l'inactivité. + * Appelée dans `onPause`. + */ + private void saveLastPlayedTime() { + if (preferences != null) { + long now = System.currentTimeMillis(); + Log.d(TAG, "Enregistrement du dernier temps joué: " + now); + preferences.edit().putLong(LAST_PLAYED_TIME_KEY, now).apply(); + } else { + Log.e(TAG, "Impossible d'enregistrer le dernier temps joué, SharedPreferences est null."); + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/legion/muyue/best2048/NotificationHelper.java b/app/src/main/java/legion/muyue/best2048/NotificationHelper.java index 7805e67..4f00892 100644 --- a/app/src/main/java/legion/muyue/best2048/NotificationHelper.java +++ b/app/src/main/java/legion/muyue/best2048/NotificationHelper.java @@ -1,4 +1,9 @@ -// Fichier NotificationHelper.java +/** + * Classe utilitaire centralisant la logique de création et d'affichage des notifications + * pour l'application Best 2048. + * Gère également la création du canal de notification nécessaire pour Android 8.0 (API 26) et supérieur. + * Utilise {@link NotificationCompat} pour assurer la compatibilité avec les anciennes versions d'Android. + */ package legion.muyue.best2048; import android.app.NotificationChannel; @@ -6,86 +11,143 @@ import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; +import android.content.pm.PackageManager; // Import pour PackageManager.PERMISSION_GRANTED import android.os.Build; +import android.util.Log; // Pour le logging des erreurs/warnings + +import androidx.annotation.NonNull; // Pour marquer les paramètres non-null +import androidx.core.app.ActivityCompat; // Pour checkSelfPermission (remplace ContextCompat ici) import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; -import androidx.core.content.ContextCompat; // Pour checkSelfPermission +// import androidx.core.content.ContextCompat; // Peut être utilisé mais ActivityCompat est plus direct pour la permission ici -/** - * Classe utilitaire pour simplifier la création et l'affichage des notifications - * et la gestion du canal de notification pour l'application Best 2048. - */ public class NotificationHelper { - /** Identifiant unique du canal de notification pour cette application. */ - public static final String CHANNEL_ID = "BEST_2048_CHANNEL"; // Doit correspondre à celui utilisé avant + /** + * Identifiant unique et constant pour le canal de notification de cette application. + * Ce même ID doit être utilisé lors de la création de la notification via {@link NotificationCompat.Builder}. + * Il est visible par l'utilisateur dans les paramètres de notification de l'application sur Android 8.0+. + */ + public static final String CHANNEL_ID = "BEST_2048_CHANNEL"; // Doit correspondre à celui utilisé dans MainActivity/Service + + /** Tag pour les logs spécifiques à ce helper. */ + private static final String TAG = "NotificationHelper"; /** - * Crée le canal de notification requis pour Android 8.0 (API 26) et supérieur. - * Cette méthode est idempotente (l'appeler plusieurs fois n'a pas d'effet négatif). - * Doit être appelée avant d'afficher la première notification sur API 26+. + * Crée le canal de notification requis pour afficher des notifications sur Android 8.0 (API 26) et supérieur. + * Si le canal existe déjà, cette méthode ne fait rien (elle est idempotente). + * Doit être appelée une fois, idéalement au démarrage de l'application (par exemple dans `Application.onCreate` + * ou dans `MainActivity.onCreate`), avant d'afficher la toute première notification sur un appareil API 26+. * - * @param context Contexte applicatif. + * @param context Le contexte applicatif ({@code Context}) utilisé pour accéder aux services système. */ - public static void createNotificationChannel(Context context) { + public static void createNotificationChannel(@NonNull Context context) { + // La création de canal n'est nécessaire que sur Android Oreo (API 26) et versions ultérieures if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Récupère les chaînes de caractères pour le nom et la description du canal depuis les ressources CharSequence name = context.getString(R.string.notification_channel_name); String description = context.getString(R.string.notification_channel_description); - int importance = NotificationManager.IMPORTANCE_DEFAULT; // Importance par défaut + // Définit l'importance du canal (affecte comment la notification est présentée à l'utilisateur) + // IMPORTANCE_DEFAULT est un bon point de départ pour la plupart des notifications. + int importance = NotificationManager.IMPORTANCE_DEFAULT; + // Crée l'objet NotificationChannel NotificationChannel channel = new NotificationChannel(CHANNEL_ID, name, importance); channel.setDescription(description); - // Enregistre le canal auprès du système. + // Optionnel : Configurer d'autres propriétés du canal ici (vibration, lumière, son par défaut, etc.) + // channel.enableLights(true); + // channel.setLightColor(Color.RED); + // channel.enableVibration(true); + // channel.setVibrationPattern(new long[]{100, 200, 300, 400, 500, 400, 300, 200, 400}); + + // Récupère le NotificationManager système NotificationManager notificationManager = context.getSystemService(NotificationManager.class); + + // Enregistre le canal auprès du système. Si le canal existe déjà, aucune opération n'est effectuée. if (notificationManager != null) { + Log.d(TAG, "Création ou mise à jour du canal de notification: " + CHANNEL_ID); notificationManager.createNotificationChannel(channel); + } else { + Log.e(TAG, "NotificationManager non disponible, impossible de créer le canal."); } + } else { + Log.d(TAG, "API < 26, pas besoin de créer de canal de notification."); } } /** - * Construit et affiche une notification. - * Vérifie la permission POST_NOTIFICATIONS sur Android 13+ avant d'essayer d'afficher. + * Construit et affiche une notification standard pour l'application. + * Vérifie la permission {@code POST_NOTIFICATIONS} sur Android 13 (API 33) et supérieur + * avant de tenter d'afficher la notification. Si la permission est manquante, + * un message d'erreur est logué et la méthode se termine sans afficher de notification. * - * @param context Contexte (peut être une Activity ou un Service). - * @param title Titre de la notification. - * @param message Contenu texte de la notification. - * @param notificationId ID unique pour cette notification (permet de la mettre à jour ou l'annuler). + * @param context Le contexte ({@code Context}, peut être une Activity, un Service, etc.) + * utilisé pour construire la notification et accéder aux ressources. + * @param title Le titre à afficher dans la notification. + * @param message Le contenu textuel principal de la notification. + * @param notificationId Un identifiant entier unique pour cette notification. Permet de mettre à jour + * ou d'annuler cette notification spécifique plus tard en utilisant le même ID. */ - public static void showNotification(Context context, String title, String message, int notificationId) { - // Vérification de la permission pour Android 13+ + public static void showNotification(@NonNull Context context, @NonNull String title, @NonNull String message, int notificationId) { + Log.d(TAG, "Tentative d'affichage de la notification ID: " + notificationId + " Titre: " + title); + + // --- Vérification de la permission pour Android 13 (API 33) et supérieur --- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) != android.content.pm.PackageManager.PERMISSION_GRANTED) { - // Si la permission n'est pas accordée, ne pas tenter d'afficher la notification. - // L'application devrait idéalement gérer la demande de permission avant d'appeler cette méthode - // si elle sait que l'utilisateur a activé les notifications dans les paramètres. - System.err.println("Permission POST_NOTIFICATIONS manquante. Notification non affichée."); + // Utilise ActivityCompat.checkSelfPermission pour vérifier la permission + if (ActivityCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + // Si la permission n'est pas accordée, loguer un avertissement et ne pas continuer. + // L'application appelante (ex: MainActivity, Service) est responsable de demander la permission + // si l'utilisateur a indiqué vouloir recevoir des notifications. + Log.w(TAG, "Permission POST_NOTIFICATIONS manquante pour API 33+. Notification ID " + notificationId + " non affichée."); + // Optionnel : Envoyer un broadcast ou utiliser une autre méthode pour signaler l'échec ? Non, simple log suffit ici. return; + } else { + Log.d(TAG, "Permission POST_NOTIFICATIONS accordée sur API 33+."); } + } else { + Log.d(TAG, "API < 33, permission POST_NOTIFICATIONS non requise pour afficher."); } - // Intent pour ouvrir MainActivity au clic + // --- Création de l'Intent pour le clic sur la notification --- + // Ouvre MainActivity lorsque l'utilisateur clique sur la notification. Intent intent = new Intent(context, MainActivity.class); + // Flags pour gérer correctement la pile d'activités (ramène l'instance existante ou en crée une nouvelle) intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); - int flags = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) ? PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE : PendingIntent.FLAG_UPDATE_CURRENT; - PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, flags); + // Flags pour le PendingIntent (requis : IMMUTABLE sur API 31+) + int pendingIntentFlags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + pendingIntentFlags |= PendingIntent.FLAG_IMMUTABLE; + } + // Crée le PendingIntent qui enveloppe l'Intent + PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, pendingIntentFlags); - // Construction de la notification via NotificationCompat pour la compatibilité + // --- Construction de la notification via NotificationCompat --- NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_stat_notification_2048) // Votre icône de notification - .setContentTitle(title) - .setContentText(message) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) // Priorité normale - .setContentIntent(pendingIntent) // Action au clic - .setAutoCancel(true); // Ferme la notification après le clic + .setSmallIcon(R.drawable.ic_stat_notification_2048) // Icône obligatoire (doit être monochrome/blanche avec transparence) + // .setLargeIcon(BitmapFactory.decodeResource(context.getResources(), R.mipmap.ic_launcher)) // Icône optionnelle plus grande + .setContentTitle(title) // Titre de la notification + .setContentText(message) // Texte principal + .setPriority(NotificationCompat.PRIORITY_DEFAULT) // Priorité (affecte l'affichage head-up, etc.) + .setContentIntent(pendingIntent) // Action à exécuter au clic + .setAutoCancel(true) // Ferme automatiquement la notification après le clic + .setDefaults(NotificationCompat.DEFAULT_SOUND | NotificationCompat.DEFAULT_VIBRATE) // Utilise son/vibration par défaut du système (si autorisé) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC); // Visible sur l'écran de verrouillage - // Affichage de la notification + // --- Affichage de la notification --- + // Utilise NotificationManagerCompat pour la compatibilité NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + try { + // Affiche la notification. Si une notification avec le même ID existe déjà, elle est mise à jour. notificationManager.notify(notificationId, builder.build()); + Log.i(TAG, "Notification ID " + notificationId + " affichée avec succès."); } catch (SecurityException e){ - // Gérer l'exception de sécurité qui peut survenir même avec la vérification ci-dessus dans certains cas limites - System.err.println("Erreur de sécurité lors de l'affichage de la notification : " + e.getMessage()); + // Gérer l'exception de sécurité qui peut (rarement) survenir même avec la vérification ci-dessus. + Log.e(TAG, "Erreur de sécurité lors de l'affichage de la notification ID " + notificationId, e); + // Afficher un Toast ou logguer est généralement suffisant ici. + } catch (Exception ex) { + // Capturer d'autres exceptions potentielles + Log.e(TAG, "Erreur inattendue lors de l'affichage de la notification ID " + notificationId, ex); } } } \ No newline at end of file diff --git a/app/src/main/java/legion/muyue/best2048/NotificationService.java b/app/src/main/java/legion/muyue/best2048/NotificationService.java index 897e4f7..26bdba2 100644 --- a/app/src/main/java/legion/muyue/best2048/NotificationService.java +++ b/app/src/main/java/legion/muyue/best2048/NotificationService.java @@ -1,4 +1,22 @@ -// Fichier NotificationService.java +/** + * Service exécuté en arrière-plan pour tenter d'envoyer des notifications périodiques, + * comme un rappel du meilleur score ou un rappel d'inactivité. + * + *

AVERTISSEMENT IMPORTANT : Ce service utilise un {@link android.os.Handler} + * pour planifier les tâches répétitives. Cette approche est simple mais NON FIABLE + * pour les tâches en arrière-plan sur les versions modernes d'Android. Le système peut tuer + * le service à tout moment pour économiser les ressources, et le redémarrage via + * {@code START_STICKY} n'est pas garanti d'être rapide ou même de se produire sur tous les appareils + * ou dans toutes les conditions (ex: mode Doze, restrictions fabricant).

+ * + *

Pour une solution robuste et recommandée par Android pour les tâches périodiques + * en arrière-plan, il est FORTEMENT conseillé d'utiliser {@link androidx.work.WorkManager}. + * WorkManager gère les contraintes (réseau, charge), la persistance des tâches et + * garantit leur exécution même si l'application ou l'appareil redémarre.

+ * + *

Ce code est fourni à titre d'exemple de la logique de notification, mais le mécanisme + * de planification devrait être remplacé par WorkManager en production.

+ */ package legion.muyue.best2048; import android.app.Service; @@ -7,129 +25,227 @@ import android.content.SharedPreferences; import android.os.Handler; import android.os.IBinder; import android.os.Looper; // Important pour créer un Handler sur le Main Thread +import android.util.Log; + import androidx.annotation.Nullable; import java.util.concurrent.TimeUnit; -/** - * Service exécuté en arrière-plan pour envoyer des notifications périodiques - * (rappel de meilleur score, rappel d'inactivité). - * Utilise un Handler pour planifier les tâches répétitives. - * NOTE : Pour une robustesse accrue (garantie d'exécution même si l'app est tuée), - * WorkManager serait préférable en production. - */ public class NotificationService extends Service { - private static final int NOTIFICATION_ID_HIGHSCORE = 2; // Doit être différent des autres notifs - private static final int NOTIFICATION_ID_INACTIVITY = 3; - // Intervalles (exemples : 1 jour pour HS, 3 jours pour inactivité) - private static final long HIGHSCORE_INTERVAL_MS = TimeUnit.DAYS.toMillis(1); - private static final long INACTIVITY_INTERVAL_MS = TimeUnit.DAYS.toMillis(3); - private static final long CHECK_INTERVAL_MS = TimeUnit.HOURS.toMillis(6); // Intervalle de vérification plus fréquent + /** Tag pour les logs spécifiques à ce service. */ + private static final String TAG = "NotificationService"; + // --- Constantes de Notification et Intervalles --- + /** ID unique pour la notification de rappel du meilleur score. */ + private static final int NOTIFICATION_ID_HIGHSCORE = 2; // Doit être différent des autres notifs (ex: 1 pour achievement) + /** ID unique pour la notification de rappel d'inactivité. */ + private static final int NOTIFICATION_ID_INACTIVITY = 3; + + /** Intervalle minimum souhaité entre deux notifications de rappel du meilleur score (ex: 1 jour). */ + private static final long HIGHSCORE_INTERVAL_MS = TimeUnit.DAYS.toMillis(1); + /** Seuil d'inactivité : durée après laquelle une notification d'inactivité peut être envoyée (ex: 3 jours). */ + private static final long INACTIVITY_THRESHOLD_MS = TimeUnit.DAYS.toMillis(3); + /** Intervalle minimum souhaité entre deux notifications de rappel d'inactivité (ex: 1 jour, pour éviter spam si toujours inactif). */ + private static final long INACTIVITY_NOTIFICATION_COOLDOWN_MS = TimeUnit.DAYS.toMillis(1); + + /** Intervalle auquel le Handler vérifie s'il faut envoyer une notification (ex: toutes les 6 heures). */ + // NOTE: Avec WorkManager, cette vérification fréquente serait inutile, on planifierait directement la tâche au bon moment. + private static final long CHECK_INTERVAL_MS = TimeUnit.HOURS.toMillis(6); + + /** Handler pour poster les tâches Runnable périodiques. Fonctionne sur le thread principal. */ private Handler handler; + /** Runnable contenant la logique de vérification et d'envoi des notifications. */ private Runnable periodicTaskRunnable; - // Clés SharedPreferences (doivent correspondre à celles utilisées ailleurs) + // --- Constantes SharedPreferences --- + // (Doivent correspondre à celles utilisées dans MainActivity et GameStats) + /** Nom du fichier de préférences partagées. */ private static final String PREFS_NAME = "Best2048_Prefs"; + /** Clé pour lire le meilleur score global. */ private static final String HIGH_SCORE_KEY = "high_score"; - private static final String LAST_PLAYED_TIME_KEY = "last_played_time"; // Nouvelle clé + /** Clé pour lire le timestamp de la dernière partie jouée. */ + private static final String LAST_PLAYED_TIME_KEY = "last_played_time"; + /** Clé pour vérifier si les notifications sont activées par l'utilisateur. */ + private static final String NOTIFICATIONS_ENABLED_KEY = "notifications_enabled"; + /** Clé pour stocker le timestamp du dernier envoi de la notification High Score. */ + private static final String LAST_HS_NOTIFICATION_TIME = "lastHsNotificationTime"; + /** Clé pour stocker le timestamp du dernier envoi de la notification d'Inactivité. */ + private static final String LAST_INACTIVITY_NOTIFICATION_TIME = "lastInactivityNotificationTime"; + /** + * Appelée par le système lors de la première création du service. + * Initialise le Handler et le Runnable pour la tâche périodique. + * Ne crée PAS le canal de notification ici, car MainActivity le fait déjà. + */ @Override public void onCreate() { super.onCreate(); - // Utilise le Looper principal pour le Handler (simple, mais bloque si tâche longue) - // Pour des tâches plus lourdes, utiliser HandlerThread + Log.i(TAG, "onCreate: Service de notification en cours de création."); + // Utilise le Looper principal pour le Handler. Simple, mais bloque le thread principal + // si checkAndSendNotifications() devient une tâche longue. + // Pour des tâches potentiellement longues, utiliser un HandlerThread serait mieux. handler = new Handler(Looper.getMainLooper()); - // Pas besoin de créer le canal ici si MainActivity le fait déjà au démarrage - // NotificationHelper.createNotificationChannel(this); + // Définit le Runnable qui sera exécuté périodiquement periodicTaskRunnable = new Runnable() { @Override public void run() { - // Vérifie périodiquement s'il faut envoyer une notification + Log.d(TAG, "Exécution de la tâche périodique de vérification des notifications."); + // Vérifie s'il faut envoyer des notifications checkAndSendNotifications(); - // Replanifie la tâche - handler.postDelayed(this, CHECK_INTERVAL_MS); // Vérifie toutes les X heures + // Replanifie la prochaine exécution de cette même tâche + // ATTENTION: Ce mécanisme n'est pas fiable en arrière-plan. + handler.postDelayed(this, CHECK_INTERVAL_MS); + Log.d(TAG, "Prochaine vérification des notifications planifiée dans " + CHECK_INTERVAL_MS + " ms."); } }; } + /** + * Appelée lorsque le service est démarré, soit par `startService()` soit après un redémarrage du système + * (si START_STICKY est utilisé et que le système choisit de le redémarrer). + * Lance la tâche périodique de vérification des notifications. + * + * @param intent L'Intent fourni à startService(), peut être null si redémarré par le système. + * @param flags Indicateurs supplémentaires sur la demande de démarrage. + * @param startId Un identifiant unique représentant cette demande de démarrage spécifique. + * @return La valeur de retour indique comment le système doit gérer le service s'il est tué. + * {@code START_STICKY} : Le système essaiera de recréer le service après l'avoir tué, + * mais l'Intent ne sera pas redélivré (il sera null). + * NOTE: START_STICKY n'est pas une garantie d'exécution fiable pour les tâches + * périodiques en arrière-plan. Utilisez WorkManager. + */ @Override public int onStartCommand(Intent intent, int flags, int startId) { - // Lance la tâche périodique lors du démarrage du service - handler.removeCallbacks(periodicTaskRunnable); // Assure qu'il n'y a pas de doublon - handler.post(periodicTaskRunnable); // Lance immédiatement la première vérification + Log.i(TAG, "onStartCommand: Service démarré (ou redémarré). StartId: " + startId); - // START_STICKY : Le système essaiera de recréer le service s'il est tué. + // S'assure qu'il n'y a pas d'anciennes tâches en double avant de poster la nouvelle + handler.removeCallbacks(periodicTaskRunnable); + // Lance la première exécution de la tâche immédiatement (ou presque) + handler.post(periodicTaskRunnable); + Log.d(TAG, "Première vérification des notifications postée."); + + // Utiliser START_STICKY pour que le système essaie de redémarrer le service s'il est tué. + // C'est un pis-aller par rapport à WorkManager. return START_STICKY; } + /** + * Appelée lorsque le service est sur le point d'être détruit. + * Nettoie les ressources, en particulier, arrête la planification des tâches + * périodiques via le Handler pour éviter les fuites ou les exécutions non désirées. + */ @Override public void onDestroy() { super.onDestroy(); + Log.i(TAG, "onDestroy: Service de notification en cours de destruction."); // Arrête la planification des tâches lorsque le service est détruit if (handler != null && periodicTaskRunnable != null) { + Log.d(TAG, "Annulation des tâches périodiques planifiées."); handler.removeCallbacks(periodicTaskRunnable); } + // Autres nettoyages si nécessaire } + /** + * Retourne l'interface de communication au client. + * Comme c'est un service démarré (Started Service) et non lié (Bound Service), + * cette méthode retourne {@code null}. + * + * @param intent L'Intent qui a été utilisé pour se lier au service (non pertinent ici). + * @return null car ce service ne permet pas le binding. + */ @Nullable @Override public IBinder onBind(Intent intent) { - // Service non lié (Started Service) + // Ce service n'est pas conçu pour être lié. return null; } /** - * Vérifie les conditions et envoie les notifications périodiques si nécessaire. + * Vérifie les conditions pour envoyer les notifications périodiques (High Score, Inactivité). + * Lit les préférences utilisateur (notifications activées?) et les timestamps nécessaires. + * Appelle les méthodes d'affichage de notification appropriées si les conditions et + * les délais depuis le dernier envoi sont respectés. + * Met à jour le timestamp du dernier envoi après avoir tenté d'envoyer une notification. */ private void checkAndSendNotifications() { + Log.d(TAG, "checkAndSendNotifications: Début de la vérification."); SharedPreferences prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE); - boolean notificationsEnabled = prefs.getBoolean("notifications_enabled", true); // Vérifie si activé + // 1. Vérifier si les notifications sont globalement activées par l'utilisateur + boolean notificationsEnabled = prefs.getBoolean(NOTIFICATIONS_ENABLED_KEY, false); // Lire la préférence if (!notificationsEnabled) { - // Si désactivé dans les prefs, on arrête potentiellement le service? - // Ou juste ne rien envoyer. Pour l'instant, ne rien envoyer. - // stopSelf(); // Arrêterait le service + Log.i(TAG, "checkAndSendNotifications: Notifications désactivées dans les préférences, arrêt de la vérification."); + // Si les notifications sont désactivées, on pourrait arrêter le service pour économiser des ressources. + // stopSelf(); // Arrêterait le service lui-même. return; } - // --- Notification High Score (Exemple: envoyer une fois par jour si non joué depuis ?) --- - // Logique simplifiée: on envoie juste le rappel basé sur un flag ou temps (pas implémenté ici) - // Pour une vraie app, il faudrait une logique pour ne pas spammer. - // Exemple: Envoyer si le dernier envoi date de plus de HIGHSCORE_INTERVAL_MS ? - int highScore = prefs.getInt(HIGH_SCORE_KEY, 0); - // Temporairement on l'envoie à chaque check pour test (à modifier!) - // if (shouldSendHighScoreNotification()) { - showHighScoreNotificationNow(highScore); - // } + long currentTime = System.currentTimeMillis(); - - // --- Notification d'Inactivité --- - long lastPlayedTime = prefs.getLong(LAST_PLAYED_TIME_KEY, 0); - if (lastPlayedTime > 0 && (System.currentTimeMillis() - lastPlayedTime > INACTIVITY_INTERVAL_MS)) { - // Si l'inactivité dépasse le seuil - showInactivityNotificationNow(); - // Optionnel: Mettre à jour lastPlayedTime pour ne pas renvoyer immédiatement ? - // Ou attendre que l'utilisateur rejoue pour mettre à jour lastPlayedTime dans onPause. + // 2. Vérification pour la notification High Score + long lastHsNotificationTime = prefs.getLong(LAST_HS_NOTIFICATION_TIME, 0); + if (currentTime - lastHsNotificationTime > HIGHSCORE_INTERVAL_MS) { + Log.d(TAG, "checkAndSendNotifications: Intervalle pour notification High Score écoulé."); + int highScore = prefs.getInt(HIGH_SCORE_KEY, 0); + if (highScore > 0) { // N'envoie pas si le high score est 0 + Log.i(TAG, "checkAndSendNotifications: Envoi de la notification High Score."); + showHighScoreNotificationNow(highScore); + // Met à jour le timestamp du dernier envoi APRÈS avoir tenté d'envoyer + prefs.edit().putLong(LAST_HS_NOTIFICATION_TIME, currentTime).apply(); + } else { + Log.d(TAG, "checkAndSendNotifications: High Score est 0, notification non envoyée."); + } + } else { + Log.d(TAG, "checkAndSendNotifications: Intervalle pour notification High Score non écoulé."); } + + // 3. Vérification pour la notification d'Inactivité + long lastPlayedTime = prefs.getLong(LAST_PLAYED_TIME_KEY, 0); + long lastInactivityNotificationTime = prefs.getLong(LAST_INACTIVITY_NOTIFICATION_TIME, 0); + + // Condition 1: Temps depuis la dernière partie > Seuil d'inactivité + boolean isInactivityThresholdMet = (lastPlayedTime > 0 && currentTime - lastPlayedTime > INACTIVITY_THRESHOLD_MS); + // Condition 2: Temps depuis la dernière notification d'inactivité > Cooldown + boolean isCooldownMet = (currentTime - lastInactivityNotificationTime > INACTIVITY_NOTIFICATION_COOLDOWN_MS); + + if (isInactivityThresholdMet) { + Log.d(TAG, "checkAndSendNotifications: Seuil d'inactivité atteint."); + if (isCooldownMet) { + Log.i(TAG, "checkAndSendNotifications: Cooldown pour notification d'inactivité respecté. Envoi..."); + showInactivityNotificationNow(); + // Met à jour le timestamp du dernier envoi APRÈS avoir tenté d'envoyer + prefs.edit().putLong(LAST_INACTIVITY_NOTIFICATION_TIME, currentTime).apply(); + } else { + Log.d(TAG, "checkAndSendNotifications: Cooldown pour notification d'inactivité non écoulé."); + } + } else { + Log.d(TAG, "checkAndSendNotifications: Seuil d'inactivité non atteint."); + } + Log.d(TAG, "checkAndSendNotifications: Fin de la vérification."); } - /** Affiche la notification High Score */ + /** + * Prépare et affiche immédiatement la notification de rappel du meilleur score. + * Utilise {@link NotificationHelper} pour l'affichage effectif. + * + * @param highScore Le meilleur score à afficher dans la notification. + */ private void showHighScoreNotificationNow(int highScore) { String title = getString(R.string.notification_title_highscore); String message = getString(R.string.notification_text_highscore, highScore); NotificationHelper.showNotification(this, title, message, NOTIFICATION_ID_HIGHSCORE); } - /** Affiche la notification d'Inactivité */ + /** + * Prépare et affiche immédiatement la notification de rappel d'inactivité. + * Utilise {@link NotificationHelper} pour l'affichage effectif. + */ private void showInactivityNotificationNow() { String title = getString(R.string.notification_title_inactivity); String message = getString(R.string.notification_text_inactivity); NotificationHelper.showNotification(this, title, message, NOTIFICATION_ID_INACTIVITY); } - // Ajouter ici une logique plus fine si nécessaire pour savoir QUAND envoyer les notifs périodiques - // private boolean shouldSendHighScoreNotification() { ... } - } \ No newline at end of file diff --git a/sortie.txt b/sortie.txt new file mode 100644 index 0000000..f9f9922 --- /dev/null +++ b/sortie.txt @@ -0,0 +1,3570 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +// Fichier Game.java +/** + * Représente la logique métier du jeu 2048. Gère l'état du plateau de jeu, + * les déplacements, les fusions, le score de la partie en cours, et les conditions + * de victoire ou de défaite. Cette classe est conçue pour être indépendante du + * framework Android (pas de dépendance au Contexte ou aux SharedPreferences). + */ +package legion.muyue.best2048; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; // Pour les méthodes de test éventuelles + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +public class Game { + + /** Le plateau de jeu, une matrice 2D d'entiers. 0 représente une case vide. */ + private int[][] board; + /** Générateur de nombres aléatoires pour l'ajout de nouvelles tuiles. */ + private final Random randomNumberGenerator; + /** Score de la partie actuellement en cours. */ + private int currentScore = 0; + /** Meilleur score global (reçu et stocké, mais non géré logiquement ici). */ + private int highestScore = 0; + /** Taille du plateau de jeu (nombre de lignes/colonnes). */ + private static final int BOARD_SIZE = 4; + /** Indicateur si la condition de victoire (>= 2048) a été atteinte. */ + private boolean gameWon = false; + /** Indicateur si la partie est terminée (plus de mouvements possibles). */ + private boolean gameOver = false; + + /** + * Constructeur pour démarrer une nouvelle partie. + * Initialise un plateau vide, le score à 0, et ajoute deux tuiles initiales. + */ + public Game() { + this.randomNumberGenerator = new Random(); + initializeNewBoard(); + } + + /** + * Constructeur pour restaurer une partie à partir d'un état sauvegardé. + * Le meilleur score (`highestScore`) doit être défini séparément via {@link #setHighestScore(int)}. + * Recalcule les états `gameWon` et `gameOver` en fonction du plateau fourni. + * + * @param board Le plateau de jeu restauré. + * @param score Le score courant restauré. + */ + public Game(int[][] board, int score) { + // Valider les dimensions du plateau fourni ? Pourrait être ajouté. + this.board = board; + this.currentScore = score; + this.randomNumberGenerator = new Random(); + checkWinCondition(); + checkGameOverCondition(); + } + + // --- Getters / Setters --- + + /** + * Retourne la valeur de la tuile aux coordonnées spécifiées. + * @param row Ligne (0 à BOARD_SIZE-1). + * @param column Colonne (0 à BOARD_SIZE-1). + * @return Valeur de la tuile, ou 0 si les coordonnées sont invalides. + */ + public int getCellValue(int row, int column) { + if (isIndexValid(row, column)) { + return this.board[row][column]; + } + return 0; // Retourne 0 pour indice invalide + } + + /** + * Définit la valeur d'une tuile aux coordonnées spécifiées. + * Ne fait rien si les coordonnées sont invalides. + * @param row Ligne (0 à BOARD_SIZE-1). + * @param col Colonne (0 à BOARD_SIZE-1). + * @param value Nouvelle valeur de la tuile. + */ + public void setCellValue(int row, int col, int value) { + if (isIndexValid(row, col)) { + this.board[row][col] = value; + } + } + + /** @return Le score actuel de la partie. */ + public int getCurrentScore() { return currentScore; } + + /** @return Le meilleur score connu par cet objet (défini via setHighestScore). */ + public int getHighestScore() { return highestScore; } + + /** + * Met à jour la valeur du meilleur score stockée dans cet objet Game. + * Typiquement appelé par la classe gérant la persistance (MainActivity). + * @param highScore Le meilleur score global à stocker. + */ + public void setHighestScore(int highScore) { this.highestScore = highScore; } + + /** @return true si une tuile 2048 (ou plus) a été atteinte, false sinon. */ + public boolean isGameWon() { return gameWon; } + + /** @return true si aucune case n'est vide ET aucun mouvement/fusion n'est possible, false sinon. */ + public boolean isGameOver() { return gameOver; } + + /** Met à jour la valeur de gameWon si partie gagné **/ + private void setGameWon(boolean won) {this.gameWon = won;} + + /** Met à jour la valeur de gameWon si partie gagné **/ + private void setGameOver(boolean over) {this.gameOver = over;} + + /** + * Retourne une copie profonde du plateau de jeu actuel. + * Utile pour la sérialisation ou pour éviter des modifications externes non désirées. + * @return Une nouvelle matrice 2D représentant l'état actuel du plateau. + */ + public int[][] getBoard() { + int[][] copy = new int[BOARD_SIZE][BOARD_SIZE]; + for(int i=0; i emptyCells = findEmptyCells(); + if (!emptyCells.isEmpty()) { + int[] randomCell = emptyCells.get(randomNumberGenerator.nextInt(emptyCells.size())); + int value = generateRandomTileValue(); + setCellValue(randomCell[0], randomCell[1], value); + } + } + + /** + * Trouve toutes les cellules vides sur le plateau. + * @return Une liste de tableaux d'entiers `[row, col]` pour chaque cellule vide. + */ + private List findEmptyCells() { + List emptyCells = new ArrayList<>(); + for (int row = 0; row < BOARD_SIZE; row++) { + for (int col = 0; col < BOARD_SIZE; col++) { + if (board[row][col] == 0) { + emptyCells.add(new int[]{row, col}); + } + } + } + return emptyCells; + } + + /** + * Génère la valeur pour une nouvelle tuile en utilisant des probabilités prédéfinies. + * @return La valeur (2, 4, 8, ...). + */ + private int generateRandomTileValue() { + int randomValue = randomNumberGenerator.nextInt(10000); // Base 10000 pour pourcentages fins + if (randomValue < 8540) return 2; // 85.40% + if (randomValue < 9740) return 4; // 12.00% + if (randomValue < 9940) return 8; // 2.00% + if (randomValue < 9990) return 16; // 0.50% + if (randomValue < 9995) return 32; // 0.05% + if (randomValue < 9998) return 64; // 0.03% + if (randomValue < 9999) return 128;// 0.01% + return 256; // 0.01% + } + + /** + * Tente de déplacer et fusionner les tuiles vers le HAUT. + * Met à jour le score interne et vérifie les états win/gameOver. + * @return true si le plateau a été modifié, false sinon. + */ + public boolean pushUp() { return processMove(MoveDirection.UP); } + + /** + * Tente de déplacer et fusionner les tuiles vers le BAS. + * @return true si le plateau a été modifié, false sinon. + */ + public boolean pushDown() { return processMove(MoveDirection.DOWN); } + + /** + * Tente de déplacer et fusionner les tuiles vers la GAUCHE. + * @return true si le plateau a été modifié, false sinon. + */ + public boolean pushLeft() { return processMove(MoveDirection.LEFT); } + + /** + * Tente de déplacer et fusionner les tuiles vers la DROITE. + * @return true si le plateau a été modifié, false sinon. + */ + public boolean pushRight() { return processMove(MoveDirection.RIGHT); } + + /** Énumération interne pour clarifier le traitement des mouvements. */ + private enum MoveDirection { UP, DOWN, LEFT, RIGHT } + + /** + * Méthode générique pour traiter un mouvement (déplacement et fusion) dans une direction donnée. + * Contient la logique de base partagée par les méthodes pushX. + * @param direction La direction du mouvement. + * @return true si le plateau a été modifié, false sinon. + */ + private boolean processMove(MoveDirection direction) { + boolean boardChanged = false; + // Itère sur l'axe perpendiculaire au mouvement + for (int i = 0; i < BOARD_SIZE; i++) { + boolean[] hasMerged = new boolean[BOARD_SIZE]; // Pour éviter double fusion sur l'axe de mouvement + // Itère sur l'axe du mouvement, dans le bon sens + int start = (direction == MoveDirection.DOWN || direction == MoveDirection.RIGHT) ? BOARD_SIZE - 2 : 1; + int end = (direction == MoveDirection.DOWN || direction == MoveDirection.RIGHT) ? -1 : BOARD_SIZE; + int step = (direction == MoveDirection.DOWN || direction == MoveDirection.RIGHT) ? -1 : 1; + + for (int j = start; j != end; j += step) { + int row = (direction == MoveDirection.UP || direction == MoveDirection.DOWN) ? j : i; + int col = (direction == MoveDirection.LEFT || direction == MoveDirection.RIGHT) ? j : i; + + if (getCellValue(row, col) != 0) { + int currentValue = getCellValue(row, col); + int currentRow = row; + int currentCol = col; + + // Calcule la position cible après déplacement dans les cases vides + int targetRow = currentRow; + int targetCol = currentCol; + int nextRow = targetRow + ((direction == MoveDirection.UP) ? -1 : (direction == MoveDirection.DOWN) ? 1 : 0); + int nextCol = targetCol + ((direction == MoveDirection.LEFT) ? -1 : (direction == MoveDirection.RIGHT) ? 1 : 0); + + while (isIndexValid(nextRow, nextCol) && getCellValue(nextRow, nextCol) == 0) { + targetRow = nextRow; + targetCol = nextCol; + nextRow = targetRow + ((direction == MoveDirection.UP) ? -1 : (direction == MoveDirection.DOWN) ? 1 : 0); + nextCol = targetCol + ((direction == MoveDirection.LEFT) ? -1 : (direction == MoveDirection.RIGHT) ? 1 : 0); + } + + // Déplace la tuile si sa position cible est différente + if (targetRow != currentRow || targetCol != currentCol) { + setCellValue(targetRow, targetCol, currentValue); + setCellValue(currentRow, currentCol, 0); + boardChanged = true; + } + + // Vérifie la fusion potentielle avec la case suivante dans la direction du mouvement + int mergeTargetRow = targetRow + ((direction == MoveDirection.UP) ? -1 : (direction == MoveDirection.DOWN) ? 1 : 0); + int mergeTargetCol = targetCol + ((direction == MoveDirection.LEFT) ? -1 : (direction == MoveDirection.RIGHT) ? 1 : 0); + int mergeIndex = (direction == MoveDirection.UP || direction == MoveDirection.DOWN) ? mergeTargetRow : mergeTargetCol; + + if (isIndexValid(mergeTargetRow, mergeTargetCol) && + getCellValue(mergeTargetRow, mergeTargetCol) == currentValue && + !hasMerged[mergeIndex]) + { + int newValue = currentValue * 2; + setCellValue(mergeTargetRow, mergeTargetCol, newValue); + setCellValue(targetRow, targetCol, 0); // La tuile qui fusionne disparaît + currentScore += newValue; + hasMerged[mergeIndex] = true; + boardChanged = true; + } + } + } + } + // Vérifie les conditions de fin après chaque type de mouvement complet + checkWinCondition(); + checkGameOverCondition(); + return boardChanged; + } + + /** + * Vérifie si les indices de ligne et colonne sont valides pour le plateau. + * @param row Ligne. + * @param col Colonne. + * @return true si les indices sont dans les limites [0, BOARD_SIZE-1]. + */ + private boolean isIndexValid(int row, int col) { + return row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE; + } + + + /** + * Sérialise l'état essentiel du jeu (plateau et score courant) pour sauvegarde. + * Format: "val,val,...,val,score" + * @return Chaîne représentant l'état du jeu. + */ + @NonNull + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + for (int row = 0; row < BOARD_SIZE; row++) { + for (int col = 0; col < BOARD_SIZE; col++) { sb.append(board[row][col]).append(","); } + } + sb.append(currentScore); + return sb.toString(); + } + + /** + * Crée un objet Game à partir de sa représentation sérialisée (plateau + score). + * Le meilleur score doit être défini séparément après la création. + * @param serializedState La chaîne issue de {@link #toString()}. + * @return Une nouvelle instance de Game, ou null en cas d'erreur de format. + */ + public static Game deserialize(String serializedState) { + if (serializedState == null || serializedState.isEmpty()) return null; + String[] values = serializedState.split(","); + if (values.length != (BOARD_SIZE * BOARD_SIZE + 1)) return null; // +1 pour le score + int[][] newBoard = new int[BOARD_SIZE][BOARD_SIZE]; int index = 0; + try { + for (int row = 0; row < BOARD_SIZE; row++) { + for (int col = 0; col < BOARD_SIZE; col++) { newBoard[row][col] = Integer.parseInt(values[index++]); } + } + int score = Integer.parseInt(values[index]); // Le dernier élément est le score + return new Game(newBoard, score); + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { return null; } + } + + /** + * Vérifie si la condition de victoire (une tuile >= 2048) est atteinte. + * Met à jour l'état interne `gameWon`. + */ + private void checkWinCondition() { + if (!gameWon) { + for (int r=0; r= 2048) { + setGameWon(true); return; + } + } + } + + /** + * Vérifie si la condition de fin de partie est atteinte (plateau plein ET aucun mouvement possible). + * Met à jour l'état interne `gameOver`. + */ + private void checkGameOverCondition() { + if (hasEmptyCell()) { setGameOver(false); return; } // Pas game over si case vide + // Vérifie s'il existe au moins une fusion possible + for (int r=0; r0 && getCellValue(r-1,c)==current) || (r0 && getCellValue(r,c-1)==current) || (c pas game over + } + } + setGameOver(true); // Aucune case vide et aucune fusion -> game over + } + + /** + * Vérifie si le plateau contient au moins une case vide (valeur 0). + * @return true si une case vide existe, false sinon. + */ + private boolean hasEmptyCell() { + for (int r=0; r maxTile) maxTile = board[r][c]; + return maxTile; + } +}// Fichier GameStats.java +/** + * Gère la collecte, la persistance (via SharedPreferences) et l'accès + * aux statistiques du jeu 2048, pour les modes solo et multijoueur (si applicable). + */ +package legion.muyue.best2048; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.SharedPreferences; +import java.util.concurrent.TimeUnit; + +public class GameStats { + + // --- Constantes pour SharedPreferences --- + private static final String PREFS_NAME = "Best2048_Prefs"; + private static final String HIGH_SCORE_KEY = "high_score"; // Clé partagée avec Game/MainActivity + // Clés spécifiques aux statistiques + private static final String STATS_TOTAL_GAMES_PLAYED = "totalGamesPlayed"; + private static final String STATS_TOTAL_GAMES_STARTED = "totalGamesStarted"; + private static final String STATS_TOTAL_MOVES = "totalMoves"; + private static final String STATS_TOTAL_PLAY_TIME_MS = "totalPlayTimeMs"; + private static final String STATS_TOTAL_MERGES = "totalMerges"; + private static final String STATS_HIGHEST_TILE = "highestTile"; + private static final String STATS_OBJECTIVE_REACHED_COUNT = "numberOfTimesObjectiveReached"; + private static final String STATS_PERFECT_GAMES = "perfectGames"; + private static final String STATS_BEST_WINNING_TIME_MS = "bestWinningTimeMs"; + private static final String STATS_WORST_WINNING_TIME_MS = "worstWinningTimeMs"; + // ... (autres clés stats) ... + private static final String STATS_MP_GAMES_WON = "multiplayerGamesWon"; + private static final String STATS_MP_GAMES_PLAYED = "multiplayerGamesPlayed"; + private static final String STATS_MP_BEST_WINNING_STREAK = "multiplayerBestWinningStreak"; + private static final String STATS_MP_TOTAL_SCORE = "multiplayerTotalScore"; + private static final String STATS_MP_TOTAL_TIME_MS = "multiplayerTotalTimeMs"; + private static final String STATS_MP_LOSSES = "totalMultiplayerLosses"; + private static final String STATS_MP_HIGH_SCORE = "multiplayerHighScore"; + + + // --- Champs de Statistiques --- + // Générales & Solo + private int totalGamesPlayed; + private int totalGamesStarted; + private int totalMoves; + private long totalPlayTimeMs; + private int totalMerges; + private int highestTile; + private int numberOfTimesObjectiveReached; // Nombre de victoires (>= 2048) + private int perfectGames; // Concept non défini ici + private long bestWinningTimeMs; + private long worstWinningTimeMs; + private int overallHighScore; // Meilleur score global + + // Partie en cours (non persistées telles quelles) + private int currentMoves; + private long currentGameStartTimeMs; + private int mergesThisGame; + + // Multijoueur + private int multiplayerGamesWon; + private int multiplayerGamesPlayed; + private int multiplayerBestWinningStreak; + private long multiplayerTotalScore; + private long multiplayerTotalTimeMs; + private int totalMultiplayerLosses; + private int multiplayerHighestScore; + + /** Contexte nécessaire pour accéder aux SharedPreferences. */ + private final Context context; + + /** + * Constructeur. Initialise l'objet et charge immédiatement les statistiques + * depuis les SharedPreferences. + * @param context Contexte de l'application. + */ + public GameStats(Context context) { + this.context = context.getApplicationContext(); // Utilise le contexte applicatif + loadStats(); + } + + // --- Persistance (SharedPreferences) --- + + /** + * Charge toutes les statistiques persistantes depuis les SharedPreferences. + * Appelé par le constructeur. + */ + public void loadStats() { + SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + overallHighScore = prefs.getInt(HIGH_SCORE_KEY, 0); + totalGamesPlayed = prefs.getInt(STATS_TOTAL_GAMES_PLAYED, 0); + totalGamesStarted = prefs.getInt(STATS_TOTAL_GAMES_STARTED, 0); + // ... (chargement de toutes les autres clés persistantes) ... + totalMoves = prefs.getInt(STATS_TOTAL_MOVES, 0); + totalPlayTimeMs = prefs.getLong(STATS_TOTAL_PLAY_TIME_MS, 0); + totalMerges = prefs.getInt(STATS_TOTAL_MERGES, 0); + highestTile = prefs.getInt(STATS_HIGHEST_TILE, 0); + numberOfTimesObjectiveReached = prefs.getInt(STATS_OBJECTIVE_REACHED_COUNT, 0); + perfectGames = prefs.getInt(STATS_PERFECT_GAMES, 0); + bestWinningTimeMs = prefs.getLong(STATS_BEST_WINNING_TIME_MS, Long.MAX_VALUE); // MAX_VALUE comme défaut pour 'best' + worstWinningTimeMs = prefs.getLong(STATS_WORST_WINNING_TIME_MS, 0); + multiplayerGamesWon = prefs.getInt(STATS_MP_GAMES_WON, 0); + multiplayerGamesPlayed = prefs.getInt(STATS_MP_GAMES_PLAYED, 0); + multiplayerBestWinningStreak = prefs.getInt(STATS_MP_BEST_WINNING_STREAK, 0); + multiplayerTotalScore = prefs.getLong(STATS_MP_TOTAL_SCORE, 0); + multiplayerTotalTimeMs = prefs.getLong(STATS_MP_TOTAL_TIME_MS, 0); + totalMultiplayerLosses = prefs.getInt(STATS_MP_LOSSES, 0); + multiplayerHighestScore = prefs.getInt(STATS_MP_HIGH_SCORE, 0); + } + + /** + * Sauvegarde toutes les statistiques persistantes dans les SharedPreferences. + * Appelé typiquement dans `onPause` de l'activité. + */ + public void saveStats() { + SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + SharedPreferences.Editor editor = prefs.edit(); + editor.putInt(HIGH_SCORE_KEY, overallHighScore); // Sauvegarde le HS global + editor.putInt(STATS_TOTAL_GAMES_PLAYED, totalGamesPlayed); + editor.putInt(STATS_TOTAL_GAMES_STARTED, totalGamesStarted); + // ... (sauvegarde de toutes les autres clés persistantes) ... + editor.putInt(STATS_TOTAL_MOVES, totalMoves); + editor.putLong(STATS_TOTAL_PLAY_TIME_MS, totalPlayTimeMs); + editor.putInt(STATS_TOTAL_MERGES, totalMerges); + editor.putInt(STATS_HIGHEST_TILE, highestTile); + editor.putInt(STATS_OBJECTIVE_REACHED_COUNT, numberOfTimesObjectiveReached); + editor.putInt(STATS_PERFECT_GAMES, perfectGames); + editor.putLong(STATS_BEST_WINNING_TIME_MS, bestWinningTimeMs); + editor.putLong(STATS_WORST_WINNING_TIME_MS, worstWinningTimeMs); + editor.putInt(STATS_MP_GAMES_WON, multiplayerGamesWon); + editor.putInt(STATS_MP_GAMES_PLAYED, multiplayerGamesPlayed); + editor.putInt(STATS_MP_BEST_WINNING_STREAK, multiplayerBestWinningStreak); + editor.putLong(STATS_MP_TOTAL_SCORE, multiplayerTotalScore); + editor.putLong(STATS_MP_TOTAL_TIME_MS, multiplayerTotalTimeMs); + editor.putInt(STATS_MP_LOSSES, totalMultiplayerLosses); + editor.putInt(STATS_MP_HIGH_SCORE, multiplayerHighestScore); + + editor.apply(); // Applique les changements de manière asynchrone + } + + // --- Méthodes de Mise à Jour des Statistiques --- + + /** + * Doit être appelée au début de chaque nouvelle partie. + * Incrémente le compteur de parties démarrées et réinitialise les stats de la partie en cours. + */ + public void startGame() { + totalGamesStarted++; + currentMoves = 0; + mergesThisGame = 0; + currentGameStartTimeMs = System.currentTimeMillis(); + } + + /** + * Enregistre un mouvement réussi (qui a modifié le plateau). + */ + public void recordMove() { + currentMoves++; + totalMoves++; + } + + /** + * Enregistre une ou plusieurs fusions survenues lors d'un mouvement. + * @param numberOfMerges Le nombre de fusions (si connu, sinon 1 par défaut). + */ + public void recordMerge(int numberOfMerges) { + if (numberOfMerges > 0) { + mergesThisGame += numberOfMerges; + totalMerges += numberOfMerges; + } + } + + /** + * Met à jour la statistique de la plus haute tuile atteinte globalement. + * @param tileValue La valeur de la tuile la plus haute de la partie en cours. + */ + public void updateHighestTile(int tileValue) { + if (tileValue > this.highestTile) { + this.highestTile = tileValue; + } + } + + /** + * Enregistre une victoire et met à jour les temps associés. + * @param timeTakenMs Temps écoulé pour cette partie gagnante. + */ + public void recordWin(long timeTakenMs) { + numberOfTimesObjectiveReached++; + if (timeTakenMs < bestWinningTimeMs) { bestWinningTimeMs = timeTakenMs; } + if (timeTakenMs > worstWinningTimeMs) { worstWinningTimeMs = timeTakenMs; } + endGame(timeTakenMs); // Finalise aussi le temps total et parties jouées + } + + /** + * Enregistre une défaite. + */ + public void recordLoss() { + // Calcule le temps écoulé avant de finaliser + endGame(System.currentTimeMillis() - currentGameStartTimeMs); + } + + /** + * Finalise les statistiques générales à la fin d'une partie (victoire ou défaite). + * @param timeTakenMs Temps total de la partie terminée. + */ + public void endGame(long timeTakenMs) { + totalGamesPlayed++; + addPlayTime(timeTakenMs); + } + + /** + * Ajoute une durée (en ms) au temps de jeu total enregistré. + * Typiquement appelé dans `onPause`. + * @param durationMs Durée à ajouter. + */ + public void addPlayTime(long durationMs) { + if (durationMs > 0) { + this.totalPlayTimeMs += durationMs; + } + } + + /** + * Réinitialise toutes les statistiques (solo, multijoueur, high score global) + * à leurs valeurs par défaut. + */ + public void resetStats() { + // Réinitialise toutes les variables membres à 0 ou valeur initiale + overallHighScore = 0; + totalGamesPlayed = 0; + totalGamesStarted = 0; + totalMoves = 0; + totalPlayTimeMs = 0; + totalMerges = 0; + highestTile = 0; + numberOfTimesObjectiveReached = 0; + perfectGames = 0; + bestWinningTimeMs = Long.MAX_VALUE; + worstWinningTimeMs = 0; + + multiplayerGamesWon = 0; + multiplayerGamesPlayed = 0; + multiplayerBestWinningStreak = 0; + multiplayerTotalScore = 0; + multiplayerTotalTimeMs = 0; + totalMultiplayerLosses = 0; + multiplayerHighestScore = 0; + + saveStats(); + } + + // --- Getters pour l'affichage --- + public int getTotalGamesPlayed() { return totalGamesPlayed; } + public int getTotalGamesStarted() { return totalGamesStarted; } + public int getTotalMoves() { return totalMoves; } + public int getCurrentMoves() { return currentMoves; } + public long getTotalPlayTimeMs() { return totalPlayTimeMs; } + public long getCurrentGameStartTimeMs() { return currentGameStartTimeMs; } // Utile pour calcul durée en cours + public int getMergesThisGame() { return mergesThisGame; } + public int getTotalMerges() { return totalMerges; } + public int getHighestTile() { return highestTile; } + public int getNumberOfTimesObjectiveReached() { return numberOfTimesObjectiveReached; } + public int getPerfectGames() { return perfectGames; } + public long getBestWinningTimeMs() { return bestWinningTimeMs; } + public long getWorstWinningTimeMs() { return worstWinningTimeMs; } + public int getOverallHighScore() { return overallHighScore; } // Getter pour HS global + // Getters Multiplayer + public int getMultiplayerGamesWon() { return multiplayerGamesWon; } + public int getMultiplayerGamesPlayed() { return multiplayerGamesPlayed; } + public int getMultiplayerBestWinningStreak() { return multiplayerBestWinningStreak; } + public long getMultiplayerTotalScore() { return multiplayerTotalScore; } + public long getMultiplayerTotalTimeMs() { return multiplayerTotalTimeMs; } + public int getTotalMultiplayerLosses() { return totalMultiplayerLosses; } + public int getMultiplayerHighestScore() { return multiplayerHighestScore; } + + // --- Setters --- + /** Met à jour la valeur interne du high score global. */ + public void setHighestScore(int highScore) { + // Met à jour si la nouvelle valeur est meilleure + if (highScore > this.overallHighScore) { + this.overallHighScore = highScore; + // La sauvegarde se fait via saveStats() globalement + } + } + /** Définit le timestamp de début de la partie en cours. */ + public void setCurrentGameStartTimeMs(long timeMs) { this.currentGameStartTimeMs = timeMs; } + + // --- Méthodes Calculées --- + /** @return Temps moyen par partie terminée en millisecondes. */ + public long getAverageGameTimeMs() { return (totalGamesPlayed > 0) ? totalPlayTimeMs / totalGamesPlayed : 0; } + /** @return Score moyen par partie multijoueur terminée. */ + public int getMultiplayerAverageScore() { return (multiplayerGamesPlayed > 0) ? (int)(multiplayerTotalScore / multiplayerGamesPlayed) : 0; } + /** @return Temps moyen par partie multijoueur terminée en millisecondes. */ + public long getMultiplayerAverageTimeMs() { return (multiplayerGamesPlayed > 0) ? multiplayerTotalTimeMs / multiplayerGamesPlayed : 0; } + + /** + * Formate une durée en millisecondes en chaîne "hh:mm:ss" ou "mm:ss". + * @param milliseconds Durée en millisecondes. + * @return Chaîne de temps formatée. + */ + @SuppressLint("DefaultLocale") + public static String formatTime(long milliseconds) { + long hours = TimeUnit.MILLISECONDS.toHours(milliseconds); + long minutes = TimeUnit.MILLISECONDS.toMinutes(milliseconds) % 60; + long seconds = TimeUnit.MILLISECONDS.toSeconds(milliseconds) % 60; + if (hours > 0) { return String.format("%02d:%02d:%02d", hours, minutes, seconds); } + else { return String.format("%02d:%02d", minutes, seconds); } + } +}// Fichier MainActivity.java +/** + * Activité principale de l'application 2048. + * Gère l'interface utilisateur (plateau, scores, boutons), coordonne les interactions + * avec la logique du jeu (classe Game) et la gestion des statistiques (classe GameStats). + * Gère également le cycle de vie de l'application et la persistance de l'état du jeu. + */ +package legion.muyue.best2048; + +import android.annotation.SuppressLint; +import android.app.AlertDialog; +import android.content.Context; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.ActivityNotFoundException; +import android.content.pm.PackageManager; +import android.os.Build; +import android.provider.Settings; +import com.google.android.material.switchmaterial.SwitchMaterial; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Bundle; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewStub; +import android.view.animation.AnimationUtils; +import android.widget.TextView; +import androidx.activity.EdgeToEdge; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.app.ActivityCompat; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; +import androidx.core.content.ContextCompat; +import androidx.gridlayout.widget.GridLayout; +import android.widget.Button; +import android.widget.Toast; +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.view.ViewTreeObserver; +import android.media.AudioAttributes; // Pour SoundPool +import android.media.SoundPool; // Pour SoundPool + +import java.util.ArrayList; +import java.util.List; + + +public class MainActivity extends AppCompatActivity { + + // --- UI Elements --- + private GridLayout boardGridLayout; + private TextView currentScoreTextView; + private TextView highestScoreTextView; + private Button newGameButton; + private Button multiplayerButton; + private Button statisticsButton; + private Button menuButton; + private ViewStub statisticsViewStub; + private View inflatedStatsView; + + // --- Game Logic & Stats --- + private Game game; + private GameStats gameStats; + private static final int BOARD_SIZE = 4; + private static final String NOTIFICATION_CHANNEL_ID = "BEST_2048_CHANNEL"; + private boolean notificationsEnabled = false; + private static final String LAST_PLAYED_TIME_KEY = "last_played_time"; + + // --- State Management --- + private boolean statisticsVisible = false; + private enum GameFlowState { PLAYING, WON_DIALOG_SHOWN, GAME_OVER } + private GameFlowState currentGameState = GameFlowState.PLAYING; + + /** Références aux TextViews des tuiles actuellement affichées. */ + private TextView[][] tileViews = new TextView[BOARD_SIZE][BOARD_SIZE]; + + // --- Preferences --- + private SharedPreferences preferences; + private static final String PREFS_NAME = "Best2048_Prefs"; + private static final String HIGH_SCORE_KEY = "high_score"; + private static final String GAME_STATE_KEY = "game_state"; + + // --- Champs Son --- + private SoundPool soundPool; + private int soundMoveId = -1; // Initialise à -1 pour savoir s'ils sont chargés + private int soundMergeId = -1; + private int soundWinId = -1; + private int soundGameOverId = -1; + private boolean soundPoolLoaded = false; // Flag pour savoir si les sons sont prêts + private boolean soundEnabled = true; // Son activé par défaut + + // --- Activity Lifecycle --- + + private final ActivityResultLauncher requestPermissionLauncher = + registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> { + if (isGranted) { + // La permission est accordée. On peut activer/planifier les notifications. + notificationsEnabled = true; + saveNotificationPreference(true); + Toast.makeText(this, R.string.notifications_enabled, Toast.LENGTH_SHORT).show(); + // Ici, on pourrait (re)planifier les notifications périodiques avec WorkManager/AlarmManager + } else { + // La permission est refusée. L'utilisateur ne recevra pas de notifications. + notificationsEnabled = false; + saveNotificationPreference(false); + // Désactive le switch dans les paramètres si l'utilisateur vient de refuser + updateNotificationSwitchState(false); + Toast.makeText(this, R.string.notifications_disabled, Toast.LENGTH_SHORT).show(); + // Afficher une explication si nécessaire + // showNotificationPermissionRationale(); + } + }); + + @Override + protected void onCreate(Bundle savedInstanceState) { + EdgeToEdge.enable(this); + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + NotificationHelper.createNotificationChannel(this); + findViews(); + initializeSoundPool(); + initializeGameAndStats(); + setupListeners(); + if (notificationsEnabled) { + startNotificationService(); + } + } + + /** + * Synchronise COMPLETEMENT le GridLayout avec l'état actuel de 'game.board'. + * Étape 1: Ajoute 16 vues de fond pour "fixer" la structure de la grille. + * Étape 2: Ajoute les TextViews des tuiles réelles (valeur > 0) par-dessus. + * Stocke les références des tuiles réelles dans tileViews. + */ + private void syncBoardView() { + // Vérifications de sécurité + if (game == null || boardGridLayout == null) { + System.err.println("syncBoardView: Game ou GridLayout est null !"); + return; + } + // Log.d("SyncDebug", "Syncing board view (Background + Tiles)..."); + + // --- Réinitialisation --- + boardGridLayout.removeAllViews(); // Vide complètement le GridLayout visuel + // Réinitialise le tableau qui stocke les références aux *vraies* tuiles (pas les fonds) + for (int r = 0; r < BOARD_SIZE; r++) { + for (int c = 0; c < BOARD_SIZE; c++) { + tileViews[r][c] = null; + } + } + + // Récupère la marge une seule fois + int gridMargin = (int) getResources().getDimension(R.dimen.tile_margin); + + // --- Étape 1: Ajouter les 16 vues de fond pour définir la grille --- + for (int r = 0; r < BOARD_SIZE; r++) { + for (int c = 0; c < BOARD_SIZE; c++) { + // Utiliser un simple View pour le fond est suffisant + View backgroundCell = new View(this); + + // Appliquer le style d'une cellule vide + // Utilise le même drawable que les tuiles pour les coins arrondis, mais avec la couleur de fond vide + backgroundCell.setBackgroundResource(R.drawable.tile_background); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + // Utiliser setTintList pour la compatibilité et éviter de recréer des drawables + backgroundCell.getBackground().setTintList(ContextCompat.getColorStateList(this, R.color.tile_empty)); + } else { + // Pour les versions plus anciennes, une approche différente pourrait être nécessaire si setTintList n'est pas dispo + // ou utiliser un drawable spécifique pour le fond. Ici on suppose API 21+ pour setTintList. + // Alternative simple mais moins propre : backgroundCell.setBackgroundColor(ContextCompat.getColor(this, R.color.tile_empty)); (perd les coins arrondis) + } + + + // Définir les LayoutParams pour positionner cette vue de fond dans la grille + GridLayout.LayoutParams params = new GridLayout.LayoutParams(); + params.width = 0; + params.height = 0; + // Spécifier ligne, colonne, span=1, et poids=1 pour occuper la cellule + params.rowSpec = GridLayout.spec(r, 1, 1f); + params.columnSpec = GridLayout.spec(c, 1, 1f); + params.setMargins(gridMargin, gridMargin, gridMargin, gridMargin); + backgroundCell.setLayoutParams(params); + + // Ajouter cette vue de fond au GridLayout + boardGridLayout.addView(backgroundCell); + } + } + + // --- Étape 2: Ajouter les TextViews des tuiles réelles (par-dessus les fonds) --- + for (int r = 0; r < BOARD_SIZE; r++) { + for (int c = 0; c < BOARD_SIZE; c++) { + int value = game.getCellValue(r, c); // Récupère la valeur logique + + // Si la case logique contient une tuile réelle + if (value > 0) { + // Crée la TextView stylisée pour cette tuile via notre méthode helper + // createTileTextView utilise les mêmes LayoutParams (row, col, span 1, weight 1, margins) + TextView tileTextView = createTileTextView(value, r, c); + + // Stocke la référence à cette TextView de tuile (pas la vue de fond) + tileViews[r][c] = tileTextView; + + // Ajoute la TextView de la tuile au GridLayout. + // Comme elle est ajoutée après la vue de fond pour la même cellule (r,c) + // et utilise les mêmes paramètres de positionnement, elle s'affichera par-dessus. + boardGridLayout.addView(tileTextView); + // Log.d("SyncDebug", "Added TILE view at ["+r+","+c+"] with value "+value); + } + } + } + // Log.d("SyncDebug", "Board sync finished."); + + // Met à jour l'affichage textuel des scores + updateScores(); + } + + /** + * Crée et configure une TextView pour une tuile. + * @param value Valeur de la tuile. + * @param row Ligne. + * @param col Colonne. + * @return La TextView configurée. + */ + private TextView createTileTextView(int value, int row, int col) { + TextView tileTextView = new TextView(this); + setTileStyle(tileTextView, value); // Applique le style visuel + + GridLayout.LayoutParams params = new GridLayout.LayoutParams(); + params.width = 0; // Largeur gérée par GridLayout basé sur la colonne/poids + params.height = 0; // Hauteur gérée par GridLayout basé sur la ligne/poids + + // --- Modification : Spécifier explicitement le span (taille) à 1 --- + // Utilisation de spec(start, size, weight) + params.rowSpec = GridLayout.spec(row, 1, 1f); // Commence à 'row', occupe 1 ligne, poids 1 + params.columnSpec = GridLayout.spec(col, 1, 1f); // Commence à 'col', occupe 1 colonne, poids 1 + // --- Fin Modification --- + + int margin = (int) getResources().getDimension(R.dimen.tile_margin); + params.setMargins(margin, margin, margin, margin); + tileTextView.setLayoutParams(params); + + return tileTextView; + } + + @Override + protected void onResume() { + super.onResume(); + // Redémarre le timer seulement si le jeu est en cours (pas gagné/perdu) + if (game != null && gameStats != null && currentGameState == GameFlowState.PLAYING) { + gameStats.setCurrentGameStartTimeMs(System.currentTimeMillis()); + } + // Gère le réaffichage potentiel des stats si l'activité reprend + if (statisticsVisible) { + if (inflatedStatsView != null) { // Si déjà gonflé + updateStatisticsTextViews(); // Met à jour les données affichées + inflatedStatsView.setVisibility(View.VISIBLE); + multiplayerButton.setVisibility(View.GONE); + } else { + // Si pas encore gonflé (cas rare mais possible), on le fait afficher + toggleStatistics(); + } + } + } + + @Override + protected void onPause() { + super.onPause(); + // Sauvegarde l'état et les stats si le jeu existe + if (game != null && gameStats != null) { + // Met à jour le temps total SI la partie était en cours + if (currentGameState == GameFlowState.PLAYING) { + gameStats.addPlayTime(System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs()); // Utilise méthode GameStats + } + saveGame(); // Sauvegarde l'état du jeu (plateau + score courant) et le HS + gameStats.saveStats(); // Sauvegarde toutes les stats via GameStats + } + } + + /** Sauvegarde le timestamp actuel comme dernier moment joué. */ + private void saveLastPlayedTime() { + if (preferences != null) { + preferences.edit().putLong(LAST_PLAYED_TIME_KEY, System.currentTimeMillis()).apply(); + } + } + + @Override + protected void onDestroy() { // Important de libérer SoundPool + super.onDestroy(); + if (soundPool != null) { + soundPool.release(); + soundPool = null; + } + } + + // --- Initialisation --- + + /** + * Récupère les références des vues du layout principal via leur ID. + */ + private void findViews() { + boardGridLayout = findViewById(R.id.gameBoard); + currentScoreTextView = findViewById(R.id.scoreLabel); + highestScoreTextView = findViewById(R.id.highScoreLabel); + newGameButton = findViewById(R.id.restartButton); + statisticsButton = findViewById(R.id.statsButton); + menuButton = findViewById(R.id.menuButton); + multiplayerButton = findViewById(R.id.multiplayerButton); + statisticsViewStub = findViewById(R.id.statsViewStub); + } + + /** + * Initialise les objets Game et GameStats. + * Charge l'état du jeu sauvegardé (s'il existe) et le meilleur score. + * Met à jour l'interface utilisateur initiale. + */ + private void initializeGameAndStats() { + preferences = getSharedPreferences(PREFS_NAME, MODE_PRIVATE); + gameStats = new GameStats(this); + loadNotificationPreference(); + loadSoundPreference(); + loadGame(); // Charge jeu et met à jour high score + updateUI(); + if (game == null) { + // Si loadGame échoue ou aucune sauvegarde, startNewGame gère l'initialisation + startNewGame(); + } + // L'état (currentGameState) est défini dans loadGame ou startNewGame + } + + /** Initialise le SoundPool et charge les effets sonores. */ + private void initializeSoundPool() { + // Configuration pour les effets sonores de jeu + AudioAttributes attributes = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_GAME) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build(); + + // Crée le SoundPool + soundPool = new SoundPool.Builder() + .setMaxStreams(3) // Nombre max de sons joués simultanément + .setAudioAttributes(attributes) + .build(); + + // Listener pour savoir quand les sons sont chargés + soundPool.setOnLoadCompleteListener((soundPool, sampleId, status) -> { + if (status == 0) { + // Vérifie si TOUS les sons sont chargés (ou gère individuellement) + // Ici, on met juste un flag général pour simplifier + soundPoolLoaded = true; + } + }); + + // Charge les sons depuis res/raw + // Le 3ème argument (priority) est 1 (priorité normale) + try { + soundMoveId = soundPool.load(this, R.raw.move, 1); + soundMergeId = soundPool.load(this, R.raw.merge, 1); + soundWinId = soundPool.load(this, R.raw.win, 1); + soundGameOverId = soundPool.load(this, R.raw.game_over, 1); + } catch (Exception e) { + // Gérer l'erreur, peut-être désactiver le son + soundEnabled = false; + } + } + + // --- Préférences Son --- + + /** Sauvegarde la préférence d'activation du son. */ + private void saveSoundPreference(boolean enabled) { + if (preferences != null) { + preferences.edit().putBoolean("sound_enabled", enabled).apply(); + } + } + + /** Charge la préférence d'activation du son. */ + private void loadSoundPreference() { + if (preferences != null) { + soundEnabled = preferences.getBoolean("sound_enabled", true); // Son activé par défaut + } else { + soundEnabled = true; + } + } + + // --- Lecture Son --- + + /** + * Joue un effet sonore si le SoundPool est chargé et si le son est activé. + * @param soundId L'ID du son retourné par soundPool.load(). + */ + private void playSound(int soundId) { + if (soundPoolLoaded && soundEnabled && soundPool != null && soundId > 0) { + // Arguments: soundID, leftVolume, rightVolume, priority, loop, rate + soundPool.play(soundId, 1.0f, 1.0f, 1, 0, 1.0f); + } + } + + /** Crée le canal de notification nécessaire pour Android 8.0+. */ + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + CharSequence name = getString(R.string.notification_channel_name); + String description = getString(R.string.notification_channel_description); + int importance = NotificationManager.IMPORTANCE_DEFAULT; + NotificationChannel channel = new NotificationChannel(NOTIFICATION_CHANNEL_ID, name, importance); + channel.setDescription(description); + // Enregistre le canal avec le système; ne peut pas être changé après ça + NotificationManager notificationManager = getSystemService(NotificationManager.class); + if (notificationManager != null) { + notificationManager.createNotificationChannel(channel); + } + } + } + + /** + * Configure les listeners pour les boutons et le plateau de jeu (swipes). + * Mise à jour pour le bouton Menu. + */ + private void setupListeners() { + newGameButton.setOnClickListener(v -> { + v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press)); + showRestartConfirmationDialog(); + }); + statisticsButton.setOnClickListener(v -> { + v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press)); + toggleStatistics(); + }); + // Modifié pour appeler la nouvelle méthode showMenu() + menuButton.setOnClickListener(v -> { + v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press)); + showMenu(); // Appelle la méthode du menu + }); + multiplayerButton.setOnClickListener(v -> { + v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press)); + showMultiplayerScreen(); // Affiche dialogue placeholder + }); + setupSwipeListener(); + } + + // --- Mise à jour UI --- + + /** + * Met à jour complètement l'interface utilisateur (plateau et scores). + */ + private void updateUI() { + if (game == null) return; + updateBoard(); + updateScores(); + } + + /** + * Redessine le plateau de jeu en créant/mettant à jour les TextViews des tuiles. + */ + private void updateBoard() { + boardGridLayout.removeAllViews(); + for (int row = 0; row < BOARD_SIZE; row++) { + for (int col = 0; col < BOARD_SIZE; col++) { + TextView tileTextView = new TextView(this); + int value = game.getCellValue(row, col); + setTileStyle(tileTextView, value); + // Définit les LayoutParams pour que la tuile remplisse la cellule du GridLayout + GridLayout.LayoutParams params = new GridLayout.LayoutParams(); + params.width = 0; params.height = 0; // Poids gère la taille + params.rowSpec = GridLayout.spec(row, 1f); // Prend 1 fraction de l'espace en hauteur + params.columnSpec = GridLayout.spec(col, 1f); // Prend 1 fraction de l'espace en largeur + int margin = (int) getResources().getDimension(R.dimen.tile_margin); + params.setMargins(margin, margin, margin, margin); + tileTextView.setLayoutParams(params); + boardGridLayout.addView(tileTextView); + } + } + } + + /** + * Met à jour les TextViews affichant le score courant et le meilleur score. + */ + private void updateScores() { + currentScoreTextView.setText(getString(R.string.score_placeholder, game.getCurrentScore())); + highestScoreTextView.setText(getString(R.string.high_score_placeholder, game.getHighestScore())); + } + + /** + * Applique le style visuel (fond, texte, taille) à une TextView représentant une tuile. + * @param tileTextView La TextView de la tuile. + * @param value La valeur numérique de la tuile (0 pour vide). + */ + private void setTileStyle(TextView tileTextView, int value) { + tileTextView.setText(value > 0 ? String.valueOf(value) : ""); + tileTextView.setGravity(Gravity.CENTER); + tileTextView.setTypeface(null, android.graphics.Typeface.BOLD); + int backgroundColorId; int textColorId; int textSizeId; + switch (value) { + case 0: backgroundColorId = R.color.tile_empty; textColorId = android.R.color.transparent; textSizeId = R.dimen.text_size_tile_small; break; + case 2: backgroundColorId = R.color.tile_2; textColorId = R.color.text_tile_low; textSizeId = R.dimen.text_size_tile_small; break; + case 4: backgroundColorId = R.color.tile_4; textColorId = R.color.text_tile_low; textSizeId = R.dimen.text_size_tile_small; break; + case 8: backgroundColorId = R.color.tile_8; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_small; break; + case 16: backgroundColorId = R.color.tile_16; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_small; break; + case 32: backgroundColorId = R.color.tile_32; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_small; break; + case 64: backgroundColorId = R.color.tile_64; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_small; break; + case 128: backgroundColorId = R.color.tile_128; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_medium; break; + case 256: backgroundColorId = R.color.tile_256; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_medium; break; + case 512: backgroundColorId = R.color.tile_512; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_medium; break; + case 1024: backgroundColorId = R.color.tile_1024; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_large; break; + case 2048: backgroundColorId = R.color.tile_2048; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_large; break; + default: backgroundColorId = R.color.tile_super; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_large; break; + } + tileTextView.setBackgroundResource(R.drawable.tile_background); + tileTextView.getBackground().setTint(ContextCompat.getColor(this, backgroundColorId)); + tileTextView.setTextColor(ContextCompat.getColor(this, textColorId)); + tileTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, getResources().getDimension(textSizeId)); + } + + + // --- Gestion des Actions Utilisateur --- + + /** + * Configure le listener pour détecter les swipes sur le plateau de jeu. + */ + @SuppressLint("ClickableViewAccessibility") + private void setupSwipeListener() { + boardGridLayout.setOnTouchListener(new OnSwipeTouchListener(this, new OnSwipeTouchListener.SwipeListener() { + @Override public void onSwipeTop() { handleSwipe(Direction.UP); } + @Override public void onSwipeBottom() { handleSwipe(Direction.DOWN); } + @Override public void onSwipeLeft() { handleSwipe(Direction.LEFT); } + @Override public void onSwipeRight() { handleSwipe(Direction.RIGHT); } + })); + } + + /** + * Traite un swipe : met à jour la logique, synchronise l'affichage instantanément, + * puis lance des animations locales d'apparition/fusion sur les tuiles concernées. + * @param direction Direction du swipe. + */ + private void handleSwipe(Direction direction) { + // Bloque si jeu terminé + if (game == null || gameStats == null || currentGameState == GameFlowState.GAME_OVER) { + return; + } + // Note: On ne bloque plus sur 'isAnimating' pour cette approche + + int scoreBefore = game.getCurrentScore(); + int[][] boardBeforePush = game.getBoard(); // État avant le push + + boolean boardChanged = false; + switch (direction) { + case UP: boardChanged = game.pushUp(); break; + case DOWN: boardChanged = game.pushDown(); break; + case LEFT: boardChanged = game.pushLeft(); break; + case RIGHT: boardChanged = game.pushRight(); break; + } + + if (boardChanged) { + playSound(soundMoveId); + // Capture l'état APRÈS le push mais AVANT l'ajout de la nouvelle tuile + int[][] boardAfterPush = game.getBoard(); + + // Met à jour les stats générales + gameStats.recordMove(); + int scoreAfter = game.getCurrentScore(); + if (scoreAfter > scoreBefore) { + playSound(soundMergeId); + gameStats.recordMerge(1); // Simplifié + if (scoreAfter > game.getHighestScore()) { + game.setHighestScore(scoreAfter); + gameStats.setHighestScore(scoreAfter); + } + } + gameStats.updateHighestTile(game.getHighestTileValue()); + // updateScores(); // Le score sera mis à jour par syncBoardView + + // Ajoute la nouvelle tuile dans la logique du jeu + game.addNewTile(); + int[][] boardAfterAdd = game.getBoard(); // État final logique + + // *** Synchronise l'affichage avec l'état final logique *** + syncBoardView(); + + // *** Lance les animations locales sur les vues mises à jour *** + animateChanges(boardBeforePush, boardAfterPush, boardAfterAdd); + + // La vérification de fin de partie est maintenant dans animateChanges->finalizeMove + // pour s'assurer qu'elle est faite APRÈS les animations. + + } else { + // Mouvement invalide, on vérifie quand même si c'est la fin du jeu + if (game.isGameOver() && currentGameState != GameFlowState.GAME_OVER) { + currentGameState = GameFlowState.GAME_OVER; + long timeTaken = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs(); + gameStats.recordLoss(); gameStats.endGame(timeTaken); + showGameOverDialog(); + } + } + } + + /** + * Identifie les tuiles qui ont fusionné ou sont apparues et lance des animations + * simples (scale/alpha) sur les vues correspondantes DÉJÀ positionnées par syncBoardView. + * @param boardBeforePush État avant le déplacement/fusion. + * @param boardAfterPush État après déplacement/fusion, avant ajout nouvelle tuile. + * @param boardAfterAdd État final après ajout nouvelle tuile. + */ + private void animateChanges(int[][] boardBeforePush, int[][] boardAfterPush, int[][] boardAfterAdd) { + List animations = new ArrayList<>(); + + for (int r = 0; r < BOARD_SIZE; r++) { + for (int c = 0; c < BOARD_SIZE; c++) { + TextView currentView = tileViews[r][c]; // Vue à la position finale (après syncBoardView) + if (currentView == null) continue; // Pas de vue à animer ici + + int valueAfterAdd = boardAfterAdd[r][c]; + int valueAfterPush = boardAfterPush[r][c]; // Valeur avant l'ajout + int valueBeforePush = boardBeforePush[r][c]; // Valeur tout au début + + // 1. Animation d'Apparition + // Si la case était vide après le push, mais a une valeur maintenant (c'est la nouvelle tuile) + if (valueAfterPush == 0 && valueAfterAdd > 0) { + //Log.d("AnimationDebug", "Animating APPEAR at ["+r+","+c+"]"); + currentView.setScaleX(0.3f); currentView.setScaleY(0.3f); currentView.setAlpha(0f); + Animator appear = createAppearAnimation(currentView); + animations.add(appear); + } + // 2. Animation de Fusion + // Si la valeur a changé PENDANT le push (valeur après push > valeur avant push) + // ET que la case n'était pas vide avant (ce n'est pas un simple déplacement) + else if (valueAfterPush > valueBeforePush && valueBeforePush != 0) { + //Log.d("AnimationDebug", "Animating MERGE at ["+r+","+c+"]"); + Animator merge = createMergeAnimation(currentView); + animations.add(merge); + } + // Note : les tuiles qui ont simplement bougé ne sont pas animées ici. + // Les tuiles qui ont disparu (fusionnées vers une autre case) sont gérées par syncBoardView qui les supprime. + } + } + + if (!animations.isEmpty()) { + AnimatorSet animatorSet = new AnimatorSet(); + animatorSet.playTogether(animations); + animatorSet.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + // finalizeMove n'est plus responsable du déblocage, mais vérifie la fin + checkEndGameConditions(); + } + }); + animatorSet.start(); + } else { + // Si aucune animation n'a été générée (ex: mouvement sans fusion ni nouvelle tuile possible) + checkEndGameConditions(); // Vérifie quand même la fin de partie + } + } + + /** Crée une animation d'apparition (scale + alpha). */ + private Animator createAppearAnimation(View view) { + ObjectAnimator scaleX = ObjectAnimator.ofFloat(view, "scaleX", 0.3f, 1f); + ObjectAnimator scaleY = ObjectAnimator.ofFloat(view, "scaleY", 0.3f, 1f); + ObjectAnimator alpha = ObjectAnimator.ofFloat(view, "alpha", 0f, 1f); + AnimatorSet set = new AnimatorSet(); + set.playTogether(scaleX, scaleY, alpha); + set.setDuration(150); // Durée apparition + return set; + } + + /** Crée une animation de 'pulse' pour une fusion. */ + private Animator createMergeAnimation(View view) { + ObjectAnimator scaleX = ObjectAnimator.ofFloat(view, "scaleX", 1f, 1.2f, 1f); + ObjectAnimator scaleY = ObjectAnimator.ofFloat(view, "scaleY", 1f, 1.2f, 1f); + AnimatorSet set = new AnimatorSet(); + set.playTogether(scaleX, scaleY); + set.setDuration(120); // Durée pulse fusion + return set; + } + + /** + * Vérifie si le jeu est gagné ou perdu et affiche le dialogue approprié. + * Doit être appelé après la fin des animations potentielles. + */ + private void checkEndGameConditions() { + if (game == null || currentGameState == GameFlowState.GAME_OVER) return; + + if (game.isGameWon() && currentGameState == GameFlowState.PLAYING) { + currentGameState = GameFlowState.WON_DIALOG_SHOWN; + long timeTaken = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs(); + gameStats.recordWin(timeTaken); + showGameWonKeepPlayingDialog(); + // La notif est déjà envoyée dans handleSwipe si on la veut immédiate + } else if (game.isGameOver()) { + currentGameState = GameFlowState.GAME_OVER; + long timeTaken = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs(); + gameStats.recordLoss(); + gameStats.endGame(timeTaken); + showGameOverDialog(); + } + } + + /** Énumération pour les directions de swipe. */ + private enum Direction { UP, DOWN, LEFT, RIGHT } + + // --- Dialogues --- + + /** + * Affiche la boîte de dialogue demandant confirmation avant de redémarrer. + */ + private void showRestartConfirmationDialog() { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + LayoutInflater inflater = getLayoutInflater(); + View dialogView = inflater.inflate(R.layout.dialog_restart_confirm, null); + builder.setView(dialogView); + Button cancelButton = dialogView.findViewById(R.id.dialogCancelButton); + Button confirmButton = dialogView.findViewById(R.id.dialogConfirmButton); + final AlertDialog dialog = builder.create(); + cancelButton.setOnClickListener(v -> dialog.dismiss()); + confirmButton.setOnClickListener(v -> { dialog.dismiss(); startNewGame(); }); + dialog.show(); + } + + /** + * Démarre une nouvelle partie logiquement et rafraîchit l'UI. + * Réinitialise les stats de la partie en cours via `gameStats.startGame()`. + * Ferme le panneau de statistiques s'il est ouvert. + */ + private void startNewGame() { + if (gameStats == null) gameStats = new GameStats(this); // Précaution si initialisation a échoué avant + gameStats.startGame(); // Réinitialise stats de partie (temps, mouvements, etc.) + game = new Game(); // Crée un nouveau jeu logique + game.setHighestScore(gameStats.getOverallHighScore()); // Applique le meilleur score global au nouveau jeu + currentGameState = GameFlowState.PLAYING; // Définit l'état à JOUER + + // Ferme le panneau de statistiques s'il était ouvert + if (statisticsVisible) { + toggleStatistics(); // Utilise la méthode existante pour masquer proprement + } + + syncBoardView(); + + updateUI(); // Met à jour l'affichage (plateau, scores) + } + + + /** + * Affiche la boîte de dialogue quand 2048 est atteint, en utilisant un layout personnalisé. + * Propose de continuer à jouer ou de commencer une nouvelle partie. + */ + private void showGameWonKeepPlayingDialog() { + playSound(soundWinId); + AlertDialog.Builder builder = new AlertDialog.Builder(this); + LayoutInflater inflater = getLayoutInflater(); + View dialogView = inflater.inflate(R.layout.dialog_game_won, null); + builder.setView(dialogView); + builder.setCancelable(false); + + Button keepPlayingButton = dialogView.findViewById(R.id.dialogKeepPlayingButton); + Button newGameButton = dialogView.findViewById(R.id.dialogNewGameButtonWon); + final AlertDialog dialog = builder.create(); + + keepPlayingButton.setOnClickListener(v -> { + // L'état est déjà WON_DIALOG_SHOWN, on ne fait rien de spécial, le jeu continue. + dialog.dismiss(); + }); + newGameButton.setOnClickListener(v -> { + dialog.dismiss(); + startNewGame(); + }); + dialog.show(); + } + + /** + * Affiche la boîte de dialogue de fin de partie (plus de mouvements), en utilisant un layout personnalisé. + * Propose Nouvelle Partie ou Quitter. + */ + private void showGameOverDialog() { + playSound(soundGameOverId); + AlertDialog.Builder builder = new AlertDialog.Builder(this); + LayoutInflater inflater = getLayoutInflater(); + View dialogView = inflater.inflate(R.layout.dialog_game_over, null); + builder.setView(dialogView); + builder.setCancelable(false); + + TextView messageTextView = dialogView.findViewById(R.id.dialogMessageGameOver); + Button newGameButton = dialogView.findViewById(R.id.dialogNewGameButtonGameOver); + Button quitButton = dialogView.findViewById(R.id.dialogQuitButtonGameOver); + messageTextView.setText(getString(R.string.game_over_message, game.getCurrentScore())); + final AlertDialog dialog = builder.create(); + + newGameButton.setOnClickListener(v -> { + dialog.dismiss(); + startNewGame(); + }); + quitButton.setOnClickListener(v -> { + dialog.dismiss(); + finish(); // Ferme l'application + }); + dialog.show(); + } + + // --- Menu Principal --- + + /** + * Affiche la boîte de dialogue du menu principal en utilisant un layout personnalisé. + * Attache les listeners aux boutons pour déclencher les actions correspondantes. + */ + private void showMenu() { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + LayoutInflater inflater = getLayoutInflater(); + View dialogView = inflater.inflate(R.layout.dialog_main_menu, null); // Gonfle le layout personnalisé + builder.setView(dialogView); + builder.setCancelable(true); + + // Récupère les boutons du layout personnalisé + Button howToPlayButton = dialogView.findViewById(R.id.menuButtonHowToPlay); + Button settingsButton = dialogView.findViewById(R.id.menuButtonSettings); + Button aboutButton = dialogView.findViewById(R.id.menuButtonAbout); + Button returnButton = dialogView.findViewById(R.id.menuButtonReturn); + + final AlertDialog dialog = builder.create(); + + // Attache les listeners aux boutons + howToPlayButton.setOnClickListener(v -> { + dialog.dismiss(); // Ferme le menu + showHowToPlayDialog(); // Ouvre la dialogue "Comment Jouer" + }); + + settingsButton.setOnClickListener(v -> { + dialog.dismiss(); // Ferme le menu + showSettingsDialog(); // Ouvre la dialogue placeholder "Paramètres" + }); + + aboutButton.setOnClickListener(v -> { + dialog.dismiss(); // Ferme le menu + showAboutDialog(); // Ouvre la dialogue "À Propos" + }); + + returnButton.setOnClickListener(v -> { + dialog.dismiss(); // Ferme simplement le menu + }); + + dialog.show(); // Affiche la boîte de dialogue + } + + /** + * Affiche une boîte de dialogue expliquant les règles du jeu, + * en utilisant un layout personnalisé pour une meilleure présentation. + */ + private void showHowToPlayDialog() { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + LayoutInflater inflater = getLayoutInflater(); + View dialogView = inflater.inflate(R.layout.dialog_how_to_play, null); // Gonfle le layout + builder.setView(dialogView); + builder.setCancelable(true); // Permet de fermer en cliquant à côté + + // Récupère le bouton OK DANS la vue gonflée + Button okButton = dialogView.findViewById(R.id.dialogOkButtonHowToPlay); + + final AlertDialog dialog = builder.create(); + + okButton.setOnClickListener(v -> dialog.dismiss()); // Ferme simplement + + dialog.show(); + } + + /** + * Affiche la boîte de dialogue des paramètres en utilisant un layout personnalisé. + * Gère les interactions avec les différentes options. + */ + private void showSettingsDialog() { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + LayoutInflater inflater = getLayoutInflater(); + View dialogView = inflater.inflate(R.layout.dialog_settings, null); + builder.setView(dialogView).setCancelable(true); + + // Vues + SwitchMaterial switchSound = dialogView.findViewById(R.id.switchSound); + SwitchMaterial switchNotifications = dialogView.findViewById(R.id.switchNotifications); // Activé + Button permissionsButton = dialogView.findViewById(R.id.buttonManagePermissions); + Button shareStatsButton = dialogView.findViewById(R.id.buttonShareStats); + Button resetStatsButton = dialogView.findViewById(R.id.buttonResetStats); + Button quitAppButton = dialogView.findViewById(R.id.buttonQuitApp); + Button closeButton = dialogView.findViewById(R.id.buttonCloseSettings); + // Ajout boutons de test (optionnel, pour débugger) + Button testNotifHS = new Button(this); // Crée programmatiquement + testNotifHS.setText(R.string.settings_test_notif_highscore); + Button testNotifInactiv = new Button(this); + testNotifInactiv.setText(R.string.settings_test_notif_inactivity); + // Ajouter ces boutons au layout 'dialogView' si nécessaire (ex: ((LinearLayout)dialogView).addView(...) ) + + final AlertDialog dialog = builder.create(); + + // Config Son (MAINTENANT ACTIF) + switchSound.setEnabled(true); // Activé + switchSound.setChecked(soundEnabled); // État actuel chargé + switchSound.setOnCheckedChangeListener((buttonView, isChecked) -> { + soundEnabled = isChecked; // Met à jour l'état + saveSoundPreference(isChecked); // Sauvegarde la préférence + Toast.makeText(this, + isChecked ? R.string.sound_enabled : R.string.sound_disabled, + Toast.LENGTH_SHORT).show(); + }); + + // Config Notifications (Activé + Gestion Permission) + switchNotifications.setEnabled(true); // Activé + switchNotifications.setChecked(notificationsEnabled); // État actuel + switchNotifications.setOnCheckedChangeListener((buttonView, isChecked) -> { + if (isChecked) { + // L'utilisateur VEUT activer les notifications + requestNotificationPermission(); // Demande la permission si nécessaire + } else { + // L'utilisateur désactive les notifications + notificationsEnabled = false; + saveNotificationPreference(false); + Toast.makeText(this, R.string.notifications_disabled, Toast.LENGTH_SHORT).show(); + // Ici, annuler les éventuelles notifications planifiées (WorkManager/AlarmManager) + } + }); + + // Listeners autres boutons (Permissions, Share, Reset, Quit, Close) + permissionsButton.setOnClickListener(v -> { openAppSettings(); dialog.dismiss(); }); + shareStatsButton.setOnClickListener(v -> { shareStats(); dialog.dismiss(); }); + resetStatsButton.setOnClickListener(v -> { dialog.dismiss(); showResetStatsConfirmationDialog(); }); + quitAppButton.setOnClickListener(v -> { dialog.dismiss(); finishAffinity(); }); + closeButton.setOnClickListener(v -> dialog.dismiss()); + + // Listeners boutons de test (si ajoutés) + testNotifHS.setOnClickListener(v -> { showHighScoreNotification(gameStats.getOverallHighScore()); }); + testNotifInactiv.setOnClickListener(v -> { showInactivityNotification(); }); + + + dialog.show(); + } + + /** Met à jour l'état du switch notification (utile si permission refusée). */ + private void updateNotificationSwitchState(boolean isEnabled) { + // Si la vue des paramètres est actuellement affichée, met à jour le switch + View settingsDialogView = getLayoutInflater().inflate(R.layout.dialog_settings, null); // Attention, regonfler n'est pas idéal + // Mieux: Garder une référence à la vue ou au switch si la dialog est affichée. + // Pour la simplicité ici, on suppose qu'il faut rouvrir les paramètres pour voir le changement. + } + + /** Sauvegarde la préférence d'activation des notifications. */ + private void saveNotificationPreference(boolean enabled) { + if (preferences != null) { + preferences.edit().putBoolean("notifications_enabled", enabled).apply(); + } + } + + /** Charge la préférence d'activation des notifications. */ + private void loadNotificationPreference() { + if (preferences != null) { + // Change le défaut de true à false + notificationsEnabled = preferences.getBoolean("notifications_enabled", false); + } else { + notificationsEnabled = false; // Assure une valeur par défaut si prefs est null + } + } + + + /** Ouvre les paramètres système de l'application. */ + private void openAppSettings() { + Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); + Uri uri = Uri.fromParts("package", getPackageName(), null); + intent.setData(uri); + try { + startActivity(intent); + } catch (ActivityNotFoundException e) { + Toast.makeText(this, "Impossible d'ouvrir les paramètres.", Toast.LENGTH_LONG).show(); + } + } + + // --- Gestion des Permissions (Notifications) --- + + /** Demande la permission POST_NOTIFICATIONS si nécessaire (Android 13+). */ + private void requestNotificationPermission() { + // Vérifie si on est sur Android 13+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // Vérifie si la permission n'est PAS déjà accordée + if (ActivityCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + // Demande la permission + requestPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS); + // Le résultat sera géré dans le callback du requestPermissionLauncher + } else { + // La permission est déjà accordée, on peut activer directement + notificationsEnabled = true; + saveNotificationPreference(true); + Toast.makeText(this, R.string.notifications_enabled, Toast.LENGTH_SHORT).show(); + // Planifier les notifications ici si ce n'est pas déjà fait + } + } else { + // Sur les versions antérieures à Android 13, pas besoin de demander la permission + notificationsEnabled = true; + saveNotificationPreference(true); + Toast.makeText(this, R.string.notifications_enabled, Toast.LENGTH_SHORT).show(); + // Planifier les notifications ici + } + } + + // --- Logique de Notification --- + + /** + * Crée l'Intent qui sera lancé au clic sur une notification (ouvre MainActivity). + * @return PendingIntent configuré. + */ + private PendingIntent createNotificationTapIntent() { + Intent intent = new Intent(this, MainActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); // Ouvre l'app ou la ramène devant + // FLAG_IMMUTABLE est requis pour Android 12+ + int flags = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) ? PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE : PendingIntent.FLAG_UPDATE_CURRENT; + return PendingIntent.getActivity(this, 0, intent, flags); + } + + /** + * Construit et affiche une notification simple. + * @param context Contexte. + * @param title Titre de la notification. + * @param message Corps du message de la notification. + * @param notificationId ID unique pour cette notification. + */ + private void showNotification(Context context, String title, String message, int notificationId) { + // Vérifie si les notifications sont activées et si la permission est accordée (pour Android 13+) + if (!notificationsEnabled || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ActivityCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED)) { + // Ne pas envoyer la notification si désactivé ou permission manquante + return; + } + + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_stat_notification_2048) // Votre icône + .setContentTitle(title) + .setContentText(message) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(createNotificationTapIntent()) // Action au clic + .setAutoCancel(true); // Ferme la notif après clic + + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + // L'ID notificationId est utilisé pour mettre à jour une notif existante ou en afficher une nouvelle + notificationManager.notify(notificationId, builder.build()); + } + + /** Démarre le NotificationService s'il n'est pas déjà lancé. */ + private void startNotificationService() { + Intent serviceIntent = new Intent(this, NotificationService.class); + // Utiliser startForegroundService pour API 26+ si le service doit faire qqch rapidement + // mais pour une tâche périodique simple startService suffit. + startService(serviceIntent); + } + + /** Arrête le NotificationService. */ + private void stopNotificationService() { + Intent serviceIntent = new Intent(this, NotificationService.class); + stopService(serviceIntent); + } + + /** Affiche la notification d'accomplissement via le NotificationHelper. */ + private void showAchievementNotification(int tileValue) { + // Vérifie l'état global avant d'envoyer (au cas où désactivé entre-temps) + if (!notificationsEnabled) return; + String title = getString(R.string.notification_title_achievement); + String message = getString(R.string.notification_text_achievement, tileValue); + NotificationHelper.showNotification(this, title, message, 1); // ID 1 pour achievement + } + + /** Affiche la notification de rappel du meilleur score (pour test). */ + private void showHighScoreNotification(int highScore) { + String title = getString(R.string.notification_title_highscore); + String message = getString(R.string.notification_text_highscore, highScore); + // Utiliser un ID spécifique (ex: 2) + showNotification(this, title, message, 2); + // NOTE: La planification réelle utiliserait WorkManager/AlarmManager + } + + /** Affiche la notification de rappel d'inactivité (pour test). */ + private void showInactivityNotification() { + String title = getString(R.string.notification_title_inactivity); + String message = getString(R.string.notification_text_inactivity); + // Utiliser un ID spécifique (ex: 3) + showNotification(this, title, message, 3); + // NOTE: La planification réelle utiliserait WorkManager/AlarmManager et suivi du temps + } + + /** + * Crée et lance une Intent pour partager les statistiques du joueur. + */ + private void shareStats() { + if (gameStats == null) return; + + // Construit le message à partager + String shareBody = getString(R.string.share_stats_body, + gameStats.getOverallHighScore(), + gameStats.getHighestTile(), + gameStats.getNumberOfTimesObjectiveReached(), + gameStats.getTotalGamesStarted(), // Ou totalGamesPlayed ? + GameStats.formatTime(gameStats.getTotalPlayTimeMs()), + gameStats.getTotalMoves() + ); + + Intent shareIntent = new Intent(Intent.ACTION_SEND); + shareIntent.setType("text/plain"); + shareIntent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.share_stats_subject)); + shareIntent.putExtra(Intent.EXTRA_TEXT, shareBody); + + try { + startActivity(Intent.createChooser(shareIntent, getString(R.string.share_stats_title))); + } catch (ActivityNotFoundException e) { + Toast.makeText(this, "Aucune application de partage disponible.", Toast.LENGTH_SHORT).show(); + } + } + + /** + * Affiche une boîte de dialogue pour confirmer la réinitialisation des statistiques. + */ + private void showResetStatsConfirmationDialog() { + new AlertDialog.Builder(this) + .setTitle(R.string.reset_stats_confirm_title) + .setMessage(R.string.reset_stats_confirm_message) + .setPositiveButton(R.string.confirm, (dialog, which) -> { + resetStatistics(); // Appelle la méthode de réinitialisation + dialog.dismiss(); + }) + .setNegativeButton(R.string.cancel, (dialog, which) -> dialog.dismiss()) + .setIcon(android.R.drawable.ic_dialog_alert) // Icône d'avertissement + .show(); + } + + /** + * Réinitialise toutes les statistiques via GameStats et sauvegarde les changements. + * Affiche une confirmation à l'utilisateur. + */ + private void resetStatistics() { + if (gameStats != null) { + gameStats.resetStats(); // Réinitialise les stats dans l'objet + gameStats.saveStats(); // Sauvegarde les stats réinitialisées + // Met aussi à jour le highScore de l'objet Game courant (si une partie est en cours) + if(game != null){ + game.setHighestScore(gameStats.getOverallHighScore()); // Le HS est aussi reset dans GameStats + updateScores(); // Rafraichit l'affichage du HS si visible + } + Toast.makeText(this, R.string.stats_reset_confirmation, Toast.LENGTH_SHORT).show(); + // Si les stats étaient visibles, on pourrait vouloir les rafraîchir + if (statisticsVisible && inflatedStatsView != null) { + updateStatisticsTextViews(); + } + } + } + + /** + * Affiche la boîte de dialogue "À Propos" en utilisant un layout personnalisé, + * incluant des informations sur l'application et un lien cliquable vers le site web. + */ + private void showAboutDialog() { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + LayoutInflater inflater = getLayoutInflater(); + View dialogView = inflater.inflate(R.layout.dialog_about, null); // Gonfle le layout + builder.setView(dialogView); + builder.setCancelable(true); // Permet de fermer + + // Récupère les vues du layout + TextView websiteLinkTextView = dialogView.findViewById(R.id.websiteLinkTextView); + Button okButton = dialogView.findViewById(R.id.dialogOkButtonAbout); + + final AlertDialog dialog = builder.create(); + + // Rend le lien cliquable pour ouvrir le navigateur + websiteLinkTextView.setOnClickListener(v -> { + Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.about_website_url))); + try { + startActivity(browserIntent); + } catch (ActivityNotFoundException e) { + // Gère le cas où aucun navigateur n'est installé + Toast.makeText(this, "Aucun navigateur web trouvé.", Toast.LENGTH_SHORT).show(); + } + }); + + // Bouton OK pour fermer la dialogue + okButton.setOnClickListener(v -> dialog.dismiss()); + + dialog.show(); + } + + + // --- Gestion Stats UI --- + + /** + * Affiche ou masque le panneau de statistiques. + * Gonfle le layout via ViewStub si c'est la première fois. + */ + private void toggleStatistics() { + statisticsVisible = !statisticsVisible; + if (statisticsVisible) { + if (inflatedStatsView == null) { // Gonfle si pas encore fait + inflatedStatsView = statisticsViewStub.inflate(); + // Attache listener au bouton Back une fois la vue gonflée + Button backButton = inflatedStatsView.findViewById(R.id.backButton); + backButton.setOnClickListener(v -> toggleStatistics()); // Recliquer sur Back re-appelle toggle + } + updateStatisticsTextViews(); // Remplit les champs avec les données actuelles + inflatedStatsView.setVisibility(View.VISIBLE); // Affiche + multiplayerButton.setVisibility(View.GONE); // Masque bouton multi + } else { + if (inflatedStatsView != null) { // Masque si la vue existe + inflatedStatsView.setVisibility(View.GONE); + } + multiplayerButton.setVisibility(View.VISIBLE); // Réaffiche bouton multi + } + } + + /** + * Remplit les TextViews du panneau de statistiques avec les données de l'objet GameStats. + * Doit être appelé seulement après que `inflatedStatsView` a été initialisé. + */ + private void updateStatisticsTextViews() { + if (inflatedStatsView == null || gameStats == null) return; + + // Récupération des TextViews dans la vue gonflée + TextView highScoreStatsLabel = inflatedStatsView.findViewById(R.id.high_score_stats_label); + TextView totalGamesPlayedLabel = inflatedStatsView.findViewById(R.id.total_games_played_label); + TextView totalGamesStartedLabel = inflatedStatsView.findViewById(R.id.total_games_started_label); + TextView winPercentageLabel = inflatedStatsView.findViewById(R.id.win_percentage_label); + TextView totalPlayTimeLabel = inflatedStatsView.findViewById(R.id.total_play_time_label); + TextView totalMovesLabel = inflatedStatsView.findViewById(R.id.total_moves_label); + TextView currentMovesLabel = inflatedStatsView.findViewById(R.id.current_moves_label); + TextView currentGameTimeLabel = inflatedStatsView.findViewById(R.id.current_game_time_label); + TextView averageGameTimeLabel = inflatedStatsView.findViewById(R.id.average_game_time_label); + TextView bestWinningTimeLabel = inflatedStatsView.findViewById(R.id.best_winning_time_label); + TextView worstWinningTimeLabel = inflatedStatsView.findViewById(R.id.worst_winning_time_label); + TextView totalMergesLabel = inflatedStatsView.findViewById(R.id.total_merges_label); + TextView highestTileLabel = inflatedStatsView.findViewById(R.id.highest_tile_label); + TextView objectiveReachedLabel = inflatedStatsView.findViewById(R.id.number_of_time_objective_reached_label); + TextView perfectGameLabel = inflatedStatsView.findViewById(R.id.perfect_game_label); + TextView multiplayerGamesWonLabel = inflatedStatsView.findViewById(R.id.multiplayer_games_won_label); + TextView multiplayerGamesPlayedLabel = inflatedStatsView.findViewById(R.id.multiplayer_games_played_label); + TextView multiplayerWinRateLabel = inflatedStatsView.findViewById(R.id.multiplayer_win_rate_label); + TextView multiplayerBestWinningStreakLabel = inflatedStatsView.findViewById(R.id.multiplayer_best_winning_streak_label); + TextView multiplayerAverageScoreLabel = inflatedStatsView.findViewById(R.id.multiplayer_average_score_label); + TextView averageTimePerGameMultiLabel = inflatedStatsView.findViewById(R.id.average_time_per_game_label); // Potentiel ID dupliqué dans layout? + TextView totalMultiplayerLossesLabel = inflatedStatsView.findViewById(R.id.total_multiplayer_losses_label); + TextView multiplayerHighScoreLabel = inflatedStatsView.findViewById(R.id.multiplayer_high_score_label); + TextView mergesThisGameLabel = inflatedStatsView.findViewById(R.id.merges_this_game); + + // MAJ textes avec getters de gameStats + highScoreStatsLabel.setText(getString(R.string.high_score_stats, gameStats.getOverallHighScore())); + totalGamesPlayedLabel.setText(getString(R.string.total_games_played, gameStats.getTotalGamesPlayed())); + totalGamesStartedLabel.setText(getString(R.string.total_games_started, gameStats.getTotalGamesStarted())); + totalMovesLabel.setText(getString(R.string.total_moves, gameStats.getTotalMoves())); + currentMovesLabel.setText(getString(R.string.current_moves, gameStats.getCurrentMoves())); + mergesThisGameLabel.setText(getString(R.string.merges_this_game_label, gameStats.getMergesThisGame())); + totalMergesLabel.setText(getString(R.string.total_merges, gameStats.getTotalMerges())); + highestTileLabel.setText(getString(R.string.highest_tile, gameStats.getHighestTile())); + objectiveReachedLabel.setText(getString(R.string.number_of_time_objective_reached, gameStats.getNumberOfTimesObjectiveReached())); + perfectGameLabel.setText(getString(R.string.perfect_games, gameStats.getPerfectGames())); + multiplayerGamesWonLabel.setText(getString(R.string.multiplayer_games_won, gameStats.getMultiplayerGamesWon())); + multiplayerGamesPlayedLabel.setText(getString(R.string.multiplayer_games_played, gameStats.getMultiplayerGamesPlayed())); + multiplayerBestWinningStreakLabel.setText(getString(R.string.multiplayer_best_winning_streak, gameStats.getMultiplayerBestWinningStreak())); + multiplayerAverageScoreLabel.setText(getString(R.string.multiplayer_average_score, gameStats.getMultiplayerAverageScore())); + totalMultiplayerLossesLabel.setText(getString(R.string.total_multiplayer_losses, gameStats.getTotalMultiplayerLosses())); + multiplayerHighScoreLabel.setText(getString(R.string.multiplayer_high_score, gameStats.getMultiplayerHighestScore())); + + // Calculs Pourcentages + String winPercentage = (gameStats.getTotalGamesStarted() > 0) ? String.format("%.2f%%", ((double) gameStats.getNumberOfTimesObjectiveReached() / gameStats.getTotalGamesStarted()) * 100) : "N/A"; + winPercentageLabel.setText(getString(R.string.win_percentage, winPercentage)); + String multiplayerWinRate = (gameStats.getMultiplayerGamesPlayed() > 0) ? String.format("%.2f%%", ((double) gameStats.getMultiplayerGamesWon() / gameStats.getMultiplayerGamesPlayed()) * 100) : "N/A"; + multiplayerWinRateLabel.setText(getString(R.string.multiplayer_win_rate, multiplayerWinRate)); + + // Calculs Temps + totalPlayTimeLabel.setText(getString(R.string.total_play_time, GameStats.formatTime(gameStats.getTotalPlayTimeMs()))); + // Calcule le temps de la partie en cours seulement si elle n'est pas finie + long currentDurationMs = 0; + if (game != null && gameStats != null && currentGameState == GameFlowState.PLAYING && gameStats.getCurrentGameStartTimeMs() > 0) { + currentDurationMs = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs(); + } + currentGameTimeLabel.setText(getString(R.string.current_game_time, GameStats.formatTime(currentDurationMs))); + + averageGameTimeLabel.setText(getString(R.string.average_time_per_game, GameStats.formatTime(gameStats.getAverageGameTimeMs()))); + averageTimePerGameMultiLabel.setText(getString(R.string.average_time_per_game_label, GameStats.formatTime(gameStats.getMultiplayerAverageTimeMs()))); // Assurez-vous que l'ID R.string.average_time_per_game_label est correct + bestWinningTimeLabel.setText(getString(R.string.best_winning_time, (gameStats.getBestWinningTimeMs() != Long.MAX_VALUE) ? GameStats.formatTime(gameStats.getBestWinningTimeMs()) : "N/A")); + worstWinningTimeLabel.setText(getString(R.string.worst_winning_time, (gameStats.getWorstWinningTimeMs() != 0) ? GameStats.formatTime(gameStats.getWorstWinningTimeMs()) : "N/A")); + } + + + // --- Placeholders Multi --- + + /** Affiche un dialogue placeholder pour le multijoueur. */ + private void showMultiplayerScreen() { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle("Multijoueur").setMessage("Fonctionnalité multijoueur à venir !").setPositiveButton("OK", null); + builder.create().show(); + } + + // --- Sauvegarde / Chargement --- + + /** Sauvegarde l'état du jeu et le meilleur score via SharedPreferences. */ + private void saveGame() { + SharedPreferences.Editor editor = preferences.edit(); + if (game != null) { + editor.putString(GAME_STATE_KEY, game.toString()); // Sérialise Game (plateau + score courant) + // Le meilleur score est géré et sauvegardé par GameStats, mais on le sauve aussi ici pour la synchro au chargement + editor.putInt(HIGH_SCORE_KEY, game.getHighestScore()); + } else { + editor.remove(GAME_STATE_KEY); // Optionnel: nettoyer si pas de jeu + } + editor.apply(); // Utilise apply() pour une sauvegarde asynchrone + } + + /** Charge l'état du jeu depuis SharedPreferences et synchronise le meilleur score. */ + private void loadGame() { + String gameStateString = preferences.getString(GAME_STATE_KEY, null); + // Charge le meilleur score depuis les préférences (sera aussi chargé par GameStats mais on l'utilise ici pour Game) + int savedHighScore = preferences.getInt(HIGH_SCORE_KEY, 0); + + // Assure que GameStats charge son état (y compris le HS global) + if (gameStats == null) { gameStats = new GameStats(this); } // Précaution + gameStats.loadStats(); // Charge explicitement les stats (ce qui devrait inclure le HS global) + // S'assure que le HS chargé par gameStats est cohérent avec celui des prefs directes + if (savedHighScore > gameStats.getOverallHighScore()) { + gameStats.setHighestScore(savedHighScore); // Assure que GameStats a au moins le HS trouvé ici + } else { + savedHighScore = gameStats.getOverallHighScore(); // Utilise le HS de GameStats s'il est plus grand + } + + + Game loadedGame = null; + if (gameStateString != null) { + loadedGame = Game.deserialize(gameStateString); + } + + if (loadedGame != null) { + game = loadedGame; + game.setHighestScore(savedHighScore); // Applique le HS synchronisé + // Détermine l'état basé sur le jeu chargé + if (game.isGameOver()) { + currentGameState = GameFlowState.GAME_OVER; + } else if (game.isGameWon()) { + // Si on charge une partie déjà gagnée, on considère qu'on a déjà vu la dialog + currentGameState = GameFlowState.WON_DIALOG_SHOWN; + } else { + currentGameState = GameFlowState.PLAYING; + // Le timer sera (re)démarré dans onResume si l'état est PLAYING + } + } else { + // Pas de sauvegarde valide ou erreur de désérialisation -> Commence une nouvelle partie implicitement + game = null; // Sera géré par l'appel à startNewGame dans initializeGameAndStats + } + } + +} // Fin MainActivity// Fichier NotificationHelper.java +package legion.muyue.best2048; + +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; +import androidx.core.content.ContextCompat; // Pour checkSelfPermission + +/** + * Classe utilitaire pour simplifier la création et l'affichage des notifications + * et la gestion du canal de notification pour l'application Best 2048. + */ +public class NotificationHelper { + + /** Identifiant unique du canal de notification pour cette application. */ + public static final String CHANNEL_ID = "BEST_2048_CHANNEL"; // Doit correspondre à celui utilisé avant + + /** + * Crée le canal de notification requis pour Android 8.0 (API 26) et supérieur. + * Cette méthode est idempotente (l'appeler plusieurs fois n'a pas d'effet négatif). + * Doit être appelée avant d'afficher la première notification sur API 26+. + * + * @param context Contexte applicatif. + */ + public static void createNotificationChannel(Context context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + CharSequence name = context.getString(R.string.notification_channel_name); + String description = context.getString(R.string.notification_channel_description); + int importance = NotificationManager.IMPORTANCE_DEFAULT; // Importance par défaut + + NotificationChannel channel = new NotificationChannel(CHANNEL_ID, name, importance); + channel.setDescription(description); + // Enregistre le canal auprès du système. + NotificationManager notificationManager = context.getSystemService(NotificationManager.class); + if (notificationManager != null) { + notificationManager.createNotificationChannel(channel); + } + } + } + + /** + * Construit et affiche une notification. + * Vérifie la permission POST_NOTIFICATIONS sur Android 13+ avant d'essayer d'afficher. + * + * @param context Contexte (peut être une Activity ou un Service). + * @param title Titre de la notification. + * @param message Contenu texte de la notification. + * @param notificationId ID unique pour cette notification (permet de la mettre à jour ou l'annuler). + */ + public static void showNotification(Context context, String title, String message, int notificationId) { + // Vérification de la permission pour Android 13+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) != android.content.pm.PackageManager.PERMISSION_GRANTED) { + // Si la permission n'est pas accordée, ne pas tenter d'afficher la notification. + // L'application devrait idéalement gérer la demande de permission avant d'appeler cette méthode + // si elle sait que l'utilisateur a activé les notifications dans les paramètres. + System.err.println("Permission POST_NOTIFICATIONS manquante. Notification non affichée."); + return; + } + } + + // Intent pour ouvrir MainActivity au clic + Intent intent = new Intent(context, MainActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + int flags = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) ? PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE : PendingIntent.FLAG_UPDATE_CURRENT; + PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, flags); + + // Construction de la notification via NotificationCompat pour la compatibilité + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_stat_notification_2048) // Votre icône de notification + .setContentTitle(title) + .setContentText(message) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) // Priorité normale + .setContentIntent(pendingIntent) // Action au clic + .setAutoCancel(true); // Ferme la notification après le clic + + // Affichage de la notification + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + try { + notificationManager.notify(notificationId, builder.build()); + } catch (SecurityException e){ + // Gérer l'exception de sécurité qui peut survenir même avec la vérification ci-dessus dans certains cas limites + System.err.println("Erreur de sécurité lors de l'affichage de la notification : " + e.getMessage()); + } + } +}// Fichier NotificationService.java +package legion.muyue.best2048; + +import android.app.Service; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; // Important pour créer un Handler sur le Main Thread +import androidx.annotation.Nullable; +import java.util.concurrent.TimeUnit; + +/** + * Service exécuté en arrière-plan pour envoyer des notifications périodiques + * (rappel de meilleur score, rappel d'inactivité). + * Utilise un Handler pour planifier les tâches répétitives. + * NOTE : Pour une robustesse accrue (garantie d'exécution même si l'app est tuée), + * WorkManager serait préférable en production. + */ +public class NotificationService extends Service { + + private static final int NOTIFICATION_ID_HIGHSCORE = 2; // Doit être différent des autres notifs + private static final int NOTIFICATION_ID_INACTIVITY = 3; + // Intervalles (exemples : 1 jour pour HS, 3 jours pour inactivité) + private static final long HIGHSCORE_INTERVAL_MS = TimeUnit.DAYS.toMillis(1); + private static final long INACTIVITY_INTERVAL_MS = TimeUnit.DAYS.toMillis(3); + private static final long CHECK_INTERVAL_MS = TimeUnit.HOURS.toMillis(6); // Intervalle de vérification plus fréquent + + private Handler handler; + private Runnable periodicTaskRunnable; + + // Clés SharedPreferences (doivent correspondre à celles utilisées ailleurs) + private static final String PREFS_NAME = "Best2048_Prefs"; + private static final String HIGH_SCORE_KEY = "high_score"; + private static final String LAST_PLAYED_TIME_KEY = "last_played_time"; // Nouvelle clé + + @Override + public void onCreate() { + super.onCreate(); + // Utilise le Looper principal pour le Handler (simple, mais bloque si tâche longue) + // Pour des tâches plus lourdes, utiliser HandlerThread + handler = new Handler(Looper.getMainLooper()); + // Pas besoin de créer le canal ici si MainActivity le fait déjà au démarrage + // NotificationHelper.createNotificationChannel(this); + + periodicTaskRunnable = new Runnable() { + @Override + public void run() { + // Vérifie périodiquement s'il faut envoyer une notification + checkAndSendNotifications(); + // Replanifie la tâche + handler.postDelayed(this, CHECK_INTERVAL_MS); // Vérifie toutes les X heures + } + }; + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + // Lance la tâche périodique lors du démarrage du service + handler.removeCallbacks(periodicTaskRunnable); // Assure qu'il n'y a pas de doublon + handler.post(periodicTaskRunnable); // Lance immédiatement la première vérification + + // START_STICKY : Le système essaiera de recréer le service s'il est tué. + return START_STICKY; + } + + @Override + public void onDestroy() { + super.onDestroy(); + // Arrête la planification des tâches lorsque le service est détruit + if (handler != null && periodicTaskRunnable != null) { + handler.removeCallbacks(periodicTaskRunnable); + } + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + // Service non lié (Started Service) + return null; + } + + /** + * Vérifie les conditions et envoie les notifications périodiques si nécessaire. + */ + private void checkAndSendNotifications() { + SharedPreferences prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE); + boolean notificationsEnabled = prefs.getBoolean("notifications_enabled", true); // Vérifie si activé + + if (!notificationsEnabled) { + // Si désactivé dans les prefs, on arrête potentiellement le service? + // Ou juste ne rien envoyer. Pour l'instant, ne rien envoyer. + // stopSelf(); // Arrêterait le service + return; + } + + // --- Notification High Score (Exemple: envoyer une fois par jour si non joué depuis ?) --- + // Logique simplifiée: on envoie juste le rappel basé sur un flag ou temps (pas implémenté ici) + // Pour une vraie app, il faudrait une logique pour ne pas spammer. + // Exemple: Envoyer si le dernier envoi date de plus de HIGHSCORE_INTERVAL_MS ? + int highScore = prefs.getInt(HIGH_SCORE_KEY, 0); + // Temporairement on l'envoie à chaque check pour test (à modifier!) + // if (shouldSendHighScoreNotification()) { + showHighScoreNotificationNow(highScore); + // } + + + // --- Notification d'Inactivité --- + long lastPlayedTime = prefs.getLong(LAST_PLAYED_TIME_KEY, 0); + if (lastPlayedTime > 0 && (System.currentTimeMillis() - lastPlayedTime > INACTIVITY_INTERVAL_MS)) { + // Si l'inactivité dépasse le seuil + showInactivityNotificationNow(); + // Optionnel: Mettre à jour lastPlayedTime pour ne pas renvoyer immédiatement ? + // Ou attendre que l'utilisateur rejoue pour mettre à jour lastPlayedTime dans onPause. + } + } + + /** Affiche la notification High Score */ + private void showHighScoreNotificationNow(int highScore) { + String title = getString(R.string.notification_title_highscore); + String message = getString(R.string.notification_text_highscore, highScore); + NotificationHelper.showNotification(this, title, message, NOTIFICATION_ID_HIGHSCORE); + } + + /** Affiche la notification d'Inactivité */ + private void showInactivityNotificationNow() { + String title = getString(R.string.notification_title_inactivity); + String message = getString(R.string.notification_text_inactivity); + NotificationHelper.showNotification(this, title, message, NOTIFICATION_ID_INACTIVITY); + } + + // Ajouter ici une logique plus fine si nécessaire pour savoir QUAND envoyer les notifs périodiques + // private boolean shouldSendHighScoreNotification() { ... } + +}// Fichier OnSwipeTouchListener.java +/** + * Listener de vue personnalisé qui détecte les gestes de balayage (swipe) + * dans les quatre directions cardinales et notifie un listener externe. + * Utilise {@link GestureDetector} pour l'analyse des gestes. + */ +package legion.muyue.best2048; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.View; +import androidx.annotation.NonNull; + +public class OnSwipeTouchListener implements View.OnTouchListener { + + /** Détecteur de gestes standard d'Android. */ + private final GestureDetector gestureDetector; + /** Listener externe à notifier lors de la détection d'un swipe. */ + private final SwipeListener listener; + + /** + * Interface à implémenter par les classes souhaitant réagir aux événements de swipe. + */ + public interface SwipeListener { + /** Appelée lorsqu'un swipe vers le haut est détecté. */ + void onSwipeTop(); + /** Appelée lorsqu'un swipe vers le bas est détecté. */ + void onSwipeBottom(); + /** Appelée lorsqu'un swipe vers la gauche est détecté. */ + void onSwipeLeft(); + /** Appelée lorsqu'un swipe vers la droite est détecté. */ + void onSwipeRight(); + } + + /** + * Constructeur. + * @param context Contexte applicatif, nécessaire pour `GestureDetector`. + * @param listener Instance qui recevra les notifications de swipe. Ne doit pas être null. + */ + public OnSwipeTouchListener(Context context, @NonNull SwipeListener listener) { + this.gestureDetector = new GestureDetector(context, new GestureListener()); + this.listener = listener; + } + + /** + * Intercepte les événements tactiles sur la vue associée et les délègue + * au {@link GestureDetector} pour analyse. + * @param v La vue touchée. + * @param event L'événement tactile. + * @return true si le geste a été consommé par le détecteur, false sinon. + */ + @SuppressLint("ClickableViewAccessibility") + @Override + public boolean onTouch(View v, MotionEvent event) { + // Passe l'événement au GestureDetector. Si ce dernier le gère (ex: détecte un onFling), + // il retournera true, et l'événement ne sera pas propagé davantage. + return gestureDetector.onTouchEvent(event); + } + + /** + * Classe interne implémentant l'écouteur de gestes pour détecter le 'fling' (balayage rapide). + */ + private final class GestureListener extends GestureDetector.SimpleOnGestureListener { + + /** Distance minimale (en pixels) pour qu'un mouvement soit considéré comme un swipe. */ + private static final int SWIPE_THRESHOLD = 100; + /** Vitesse minimale (en pixels/sec) pour qu'un mouvement soit considéré comme un swipe. */ + private static final int SWIPE_VELOCITY_THRESHOLD = 100; + + /** + * Toujours retourner true pour onDown garantit que les événements suivants + * (comme onFling) seront bien reçus par ce listener. + */ + @Override + public boolean onDown(@NonNull MotionEvent e) { + return true; + } + + /** + * Appelée quand un geste de 'fling' (balayage rapide) est détecté. + * Analyse la direction et la vitesse pour déterminer s'il s'agit d'un swipe valide + * et notifie le {@link SwipeListener} externe. + */ + @Override + public boolean onFling(MotionEvent e1, @NonNull MotionEvent e2, float velocityX, float velocityY) { + if (e1 == null) return false; // Point de départ est nécessaire + + boolean result = false; + try { + float diffY = e2.getY() - e1.getY(); + float diffX = e2.getX() - e1.getX(); + + // Priorité au mouvement le plus ample (horizontal ou vertical) + if (Math.abs(diffX) > Math.abs(diffY)) { + // Mouvement principalement horizontal + if (Math.abs(diffX) > SWIPE_THRESHOLD && Math.abs(velocityX) > SWIPE_VELOCITY_THRESHOLD) { + if (diffX > 0) { + listener.onSwipeRight(); + } else { + listener.onSwipeLeft(); + } + result = true; // Geste horizontal traité + } + } else { + // Mouvement principalement vertical + if (Math.abs(diffY) > SWIPE_THRESHOLD && Math.abs(velocityY) > SWIPE_VELOCITY_THRESHOLD) { + if (diffY > 0) { + listener.onSwipeBottom(); + } else { + listener.onSwipeTop(); + } + result = true; // Geste vertical traité + } + } + } catch (Exception exception) { + // En cas d'erreur inattendue, on logue discrètement. + System.err.println("Erreur dans OnSwipeTouchListener.onFling: " + exception.getMessage()); + // Ne pas crasher l'application pour une erreur de détection de geste. + } + return result; + } + } +} // Fin OnSwipeTouchListener + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +