diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d58a97a..b5e750c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + + + () { + + // !!! IMPORTANT: Générer un ID unique ici !!! + // myPlayerId = "Player1_Temp"; // À REMPLACER + myPlayerId = java.util.UUID.randomUUID().toString(); // Exemple simple d'ID unique + Log.i(TAG, "Utilisation du Player ID: " + myPlayerId); + + // Crée l'objet pour le corps de la requête + PlayerIdRequest requestBody = new PlayerIdRequest(myPlayerId); + + // Utilise requestBody dans l'appel API + apiService.createOrJoinGame(requestBody).enqueue(new Callback() { @Override public void onResponse(@NonNull Call call, @NonNull Response response) { if (response.isSuccessful() && response.body() != null) { GameInfo gameInfo = response.body(); currentGameId = gameInfo.getGameId(); - // TODO: Déterminer opponentPlayerId basé sur gameInfo.getPlayer1Id/getPlayer2Id - opponentPlayerId = myPlayerId.equals(gameInfo.getPlayer1Id()) ? gameInfo.getPlayer2Id() : gameInfo.getPlayer1Id(); - Log.i(TAG, "Partie rejointe/créée: ID=" + currentGameId + ", Adversaire=" + opponentPlayerId); - statusTextMulti.setText("Partie trouvée ! ID: " + currentGameId); + // Détermine correctement l'ID de l'adversaire + if (myPlayerId.equals(gameInfo.getPlayer1Id())) { + opponentPlayerId = gameInfo.getPlayer2Id(); + } else { + opponentPlayerId = gameInfo.getPlayer1Id(); // Si je suis P2, l'autre est P1 + } + + Log.i(TAG, "Partie rejointe/créée: ID=" + currentGameId + ", Moi=" + myPlayerId + ", Adversaire=" + opponentPlayerId); + statusTextMulti.setText("Partie trouvée ! ID: " + currentGameId.substring(0, 8) + "..."); // Affiche début ID fetchGameState(); // Récupère l'état initial } else { Log.e(TAG, "Erreur création/rejoindre partie: " + response.code()); - handleNetworkError("Impossible de créer ou rejoindre une partie."); + handleNetworkError("Impossible de créer ou rejoindre une partie (Code: " + response.code() + ")"); } } diff --git a/app/src/main/java/legion/muyue/best2048/data/GameStateResponse.java b/app/src/main/java/legion/muyue/best2048/data/GameStateResponse.java index 767bd1a..7b3a527 100644 --- a/app/src/main/java/legion/muyue/best2048/data/GameStateResponse.java +++ b/app/src/main/java/legion/muyue/best2048/data/GameStateResponse.java @@ -19,6 +19,12 @@ public class GameStateResponse { private String winnerId; @SerializedName("status") private String status; + @SerializedName("player1Id") // Ajoute ce champ s'il manque + private String player1Id; + @SerializedName("player2Id") // Ajoute ce champ s'il manque + private String player2Id; + @SerializedName("targetScore") // Ajoute ce champ + private int targetScore; // --- Getters --- public String getGameId() { return gameId; } @@ -29,19 +35,30 @@ public class GameStateResponse { public boolean isGameOver() { return isGameOver; } public String getWinnerId() { return winnerId; } public String getStatus() { return status; } + public String getPlayer1Id() { return player1Id; } + public String getPlayer2Id() { return player2Id; } + public int getTargetScore() { return targetScore; } // --- Méthode utilitaire pour obtenir le score de l'adversaire --- - public int getOpponentScore(String myPlayerId) { - if (myPlayerId == null) return 0; - // TODO: Logique pour déterminer qui est player1/player2 basée sur l'API - // Supposons pour l'instant que player1 est l'hôte, player2 l'invité - // Et que l'API nous donne l'ID de player1/player2 dans un autre champ (ex: GameInfo) - // Placeholder: - return (myPlayerId.equals("player1_placeholder")) ? player2Score : player1Score; + public int getOpponentScore(String myActualPlayerId) { + if (myActualPlayerId == null) return 0; + if (myActualPlayerId.equals(player1Id)) { + // Si je suis P1, le score de l'adversaire est P2 + return player2Score; + } else if (myActualPlayerId.equals(player2Id)) { + // Si je suis P2, le score de l'adversaire est P1 + return player1Score; + } + return 0; // Mon ID ne correspond à aucun joueur ? } - public int getMyScore(String myPlayerId) { - if (myPlayerId == null) return 0; - // Placeholder: - return (myPlayerId.equals("player1_placeholder")) ? player1Score : player2Score; + + public int getMyScore(String myActualPlayerId) { + if (myActualPlayerId == null) return 0; + if (myActualPlayerId.equals(player1Id)) { + return player1Score; + } else if (myActualPlayerId.equals(player2Id)) { + return player2Score; + } + return 0; // Mon ID ne correspond à aucun joueur ? } } \ No newline at end of file diff --git a/app/src/main/java/legion/muyue/best2048/data/PlayerIdRequest.java b/app/src/main/java/legion/muyue/best2048/data/PlayerIdRequest.java new file mode 100644 index 0000000..d27d2b4 --- /dev/null +++ b/app/src/main/java/legion/muyue/best2048/data/PlayerIdRequest.java @@ -0,0 +1,10 @@ +package legion.muyue.best2048.data; + +public class PlayerIdRequest { + private String playerId; + + public PlayerIdRequest(String playerId) { + this.playerId = playerId; + } + // Pas besoin de getters si seulement utilisé pour l'envoi avec Gson +} \ No newline at end of file diff --git a/app/src/main/java/legion/muyue/best2048/network/ApiClient.java b/app/src/main/java/legion/muyue/best2048/network/ApiClient.java index ed3e2e7..1baedc6 100644 --- a/app/src/main/java/legion/muyue/best2048/network/ApiClient.java +++ b/app/src/main/java/legion/muyue/best2048/network/ApiClient.java @@ -8,7 +8,7 @@ import retrofit2.converter.gson.GsonConverterFactory; public class ApiClient { // URL de base de votre API serveur - private static final String BASE_URL = "http://best2048.legion-muyue.fr/api/"; // Assurez-vous que le chemin est correct + private static final String BASE_URL = "https://best2048.legion-muyue.fr/api/"; private static Retrofit retrofit = null; diff --git a/app/src/main/java/legion/muyue/best2048/network/ApiService.java b/app/src/main/java/legion/muyue/best2048/network/ApiService.java index 0ba0713..ed12f9e 100644 --- a/app/src/main/java/legion/muyue/best2048/network/ApiService.java +++ b/app/src/main/java/legion/muyue/best2048/network/ApiService.java @@ -9,6 +9,7 @@ import retrofit2.http.GET; import retrofit2.http.POST; import retrofit2.http.Path; import retrofit2.http.Query; // Pour éventuels paramètres de création +import legion.muyue.best2048.data.PlayerIdRequest; public interface ApiService { @@ -18,14 +19,14 @@ public interface ApiService { * @return Informations sur la partie créée/rejointe. */ @POST("games") // Endpoint: /api/games (POST) - Call createOrJoinGame(@Query("playerId") String playerId); // Exemple avec ID joueur en query param + Call createOrJoinGame(@Body PlayerIdRequest playerIdRequest); /** * Récupère l'état actuel complet d'une partie spécifique. * @param gameId L'identifiant unique de la partie. * @return L'état actuel du jeu. */ - @GET("games/{gameId}") // Endpoint: /api/games/{gameId} (GET) + @GET("games/{gameId}") Call getGameState(@Path("gameId") String gameId); /** @@ -35,7 +36,7 @@ public interface ApiService { * @param moveRequest L'objet contenant la direction du mouvement et l'ID du joueur. * @return Le nouvel état du jeu après application du mouvement (ou un message d'erreur). */ - @POST("games/{gameId}/moves") // Endpoint: /api/games/{gameId}/moves (POST) + @POST("games/{gameId}/moves") Call makeMove(@Path("gameId") String gameId, @Body MoveRequest moveRequest); } \ No newline at end of file diff --git a/sortie.txt b/sortie.txt deleted file mode 100644 index f9f9922..0000000 --- a/sortie.txt +++ /dev/null @@ -1,3570 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - -// 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 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -