645 lines
23 KiB
Markdown
645 lines
23 KiB
Markdown
# Play Games Services Android — Plan d'implémentation
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Sur Android uniquement, authentification Play Jeux Services v2 + 10 leaderboards (un par nombre de questions) avec un score pondéré, le tout silencieux et optionnel (l'app reste jouable hors connexion).
|
||
|
||
**Architecture:** Interface `GamesServices` dans commonMain injectée dans `App()` (même pattern que `filesDirPath`), implémentée par `PlayGamesServices` dans `androidApp` avec `play-services-games-v2:22.0.0`, no-op sur desktop/iOS. Formule du score = `round((Σ produits réussis) × 30 ÷ secondsPerQuestion)`.
|
||
|
||
**Tech Stack:** Kotlin Multiplatform 2.4.10, Compose Multiplatform 1.12, Google Play Services Games v2 22.0.0, kotlinx-coroutines-play-services 1.11.0.
|
||
|
||
**Spec :** `docs/superpowers/specs/2026-08-28-play-games-services-design.md`
|
||
|
||
## Global Constraints
|
||
|
||
- La dépendance GMS reste confinée à `androidApp` — sharedUI ne dépend d'aucune API Android/Google.
|
||
- Version exacte : `com.google.android.gms:play-services-games-v2:22.0.0` ; `org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.11.0`.
|
||
- Formule du score : `round((Σ produits des questions réussies) × 30 ÷ secondsPerQuestion)` ; 0 bonne réponse → pas de soumission.
|
||
- Produit d'une question : `Direct(a, b)` → `a × b` ; `MissingFactor(a, b)` → `a × b` (⚠️ `correctAnswer` d'un `MissingFactor` est le facteur manquant `b`, pas le produit).
|
||
- IDs de boards Play Console (custom) : `questions_5`, `questions_10`, … `questions_50`.
|
||
- Tous les échecs GMS sont silencieux : pas d'UI d'erreur, pas de crash, retour `false`.
|
||
- Pas de déconnexion in-app (standard PGS v2, gérée par l'app Play Jeux).
|
||
- JVM 17, minSdk 23, compileSdk 37, Kotlin 2.4.10 (cataloge existant).
|
||
- Tests : `./gradlew :sharedUI:jvmTest` ; vérif Android : `./gradlew :androidApp:assembleDebug` ; régression finale : `./gradlew build`.
|
||
- Style des commits du dépôt : `feat:` / `docs:` en français.
|
||
|
||
---
|
||
|
||
### Task 1: Score leaderboard et ID de board (logique pure, TDD)
|
||
|
||
**Files:**
|
||
- Modify: `sharedUI/src/commonMain/kotlin/com/livrets/game/Question.kt` (ajout extension `product`)
|
||
- Create: `sharedUI/src/commonMain/kotlin/com/livrets/games/LeaderboardScore.kt`
|
||
- Create: `sharedUI/src/commonMain/kotlin/com/livrets/games/Leaderboards.kt`
|
||
- Test: `sharedUI/src/commonTest/kotlin/com/livrets/games/LeaderboardScoreTest.kt`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `GameResult` (`com.livrets.game.GameResult`, champs `config`, `records`, `correctCount`), `AnswerRecord` (`question`, `isCorrect`), `Question` (sealed : `Direct(a, b)`, `MissingFactor(a, b)`), `GameConfig` (`questionCount`).
|
||
- Produces: `Question.product: Int`, `GameResult.leaderboardScore(): Long`, `GameConfig.leaderboardId(): String` — utilisés par les Tasks 3 et 4.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Créer `sharedUI/src/commonTest/kotlin/com/livrets/games/LeaderboardScoreTest.kt` :
|
||
|
||
```kotlin
|
||
package com.livrets.games
|
||
|
||
import com.livrets.game.AnswerRecord
|
||
import com.livrets.game.GameConfig
|
||
import com.livrets.game.GameResult
|
||
import com.livrets.game.Question
|
||
import com.livrets.game.product
|
||
import kotlin.test.Test
|
||
import kotlin.test.assertEquals
|
||
import kotlin.time.Instant
|
||
|
||
class LeaderboardScoreTest {
|
||
|
||
private fun result(config: GameConfig, records: List<AnswerRecord>) =
|
||
GameResult(date = Instant.parse("2026-08-28T10:00:00Z"), config = config, records = records)
|
||
|
||
private fun direct(a: Int, b: Int, correct: Boolean) = AnswerRecord(
|
||
question = Question.Direct(a, b),
|
||
givenAnswer = if (correct) a * b else a * b + 1,
|
||
correctAnswer = a * b,
|
||
isCorrect = correct,
|
||
durationMs = 1000,
|
||
)
|
||
|
||
private fun missing(a: Int, b: Int, correct: Boolean) = AnswerRecord(
|
||
question = Question.MissingFactor(a, b),
|
||
givenAnswer = if (correct) b else b + 1,
|
||
correctAnswer = b,
|
||
isCorrect = correct,
|
||
durationMs = 1000,
|
||
)
|
||
|
||
@Test
|
||
fun `produit d'une question directe et a facteur manquant`() {
|
||
assertEquals(56, Question.Direct(7, 8).product)
|
||
assertEquals(10, Question.MissingFactor(2, 5).product)
|
||
}
|
||
|
||
@Test
|
||
fun `score nul sans bonne reponse`() {
|
||
val r = result(GameConfig(5, 5, 12), listOf(direct(7, 8, false), missing(2, 5, false)))
|
||
assertEquals(0L, r.leaderboardScore())
|
||
}
|
||
|
||
@Test
|
||
fun `score = somme des produits reussis fois le facteur vitesse`() {
|
||
val r = result(
|
||
GameConfig(10, 5, 12),
|
||
listOf(direct(7, 8, true), direct(3, 4, false), missing(2, 5, true)),
|
||
)
|
||
// (56 + 10) × 30 / 5 = 396
|
||
assertEquals(396L, r.leaderboardScore())
|
||
}
|
||
|
||
@Test
|
||
fun `toutes les questions reussies`() {
|
||
val r = result(
|
||
GameConfig(5, 3, 12),
|
||
listOf(
|
||
direct(4, 6, true),
|
||
direct(7, 8, true),
|
||
direct(9, 9, true),
|
||
direct(2, 12, true),
|
||
direct(5, 5, true),
|
||
),
|
||
)
|
||
// (24 + 56 + 81 + 24 + 25) × 30 / 3 = 210 × 10 = 2100
|
||
assertEquals(2100L, r.leaderboardScore())
|
||
}
|
||
|
||
@Test
|
||
fun `arrondi au point le plus proche`() {
|
||
// 8 × 30 / 4 = 60 (exact)
|
||
assertEquals(
|
||
60L,
|
||
result(GameConfig(10, 4, 12), listOf(direct(2, 4, true))).leaderboardScore(),
|
||
)
|
||
// 7 × 30 / 4 = 52,5 → 53
|
||
assertEquals(
|
||
53L,
|
||
result(GameConfig(10, 4, 12), listOf(direct(7, 1, true))).leaderboardScore(),
|
||
)
|
||
}
|
||
|
||
@Test
|
||
fun `id de board par nombre de questions`() {
|
||
assertEquals("questions_10", GameConfig(10, 5, 12).leaderboardId())
|
||
assertEquals("questions_5", GameConfig(5, 3, 1).leaderboardId())
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `./gradlew :sharedUI:jvmTest --tests "com.livrets.games.LeaderboardScoreTest"`
|
||
Expected: FAIL — compilation error, `product` et `leaderboardScore`/`leaderboardId` introuvables.
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
Dans `sharedUI/src/commonMain/kotlin/com/livrets/game/Question.kt`, ajouter après l'extension `correctAnswer` :
|
||
|
||
```kotlin
|
||
val Question.product: Int
|
||
get() = when (this) {
|
||
is Question.Direct -> a * b
|
||
is Question.MissingFactor -> a * b
|
||
}
|
||
```
|
||
|
||
Créer `sharedUI/src/commonMain/kotlin/com/livrets/games/LeaderboardScore.kt` :
|
||
|
||
```kotlin
|
||
package com.livrets.games
|
||
|
||
import com.livrets.game.GameResult
|
||
import kotlin.math.roundToLong
|
||
|
||
fun GameResult.leaderboardScore(): Long {
|
||
if (correctCount == 0) return 0L
|
||
val products = records.filter { it.isCorrect }.sumOf { it.question.product }
|
||
return (products * 30.0 / config.secondsPerQuestion).roundToLong()
|
||
}
|
||
```
|
||
|
||
Créer `sharedUI/src/commonMain/kotlin/com/livrets/games/Leaderboards.kt` :
|
||
|
||
```kotlin
|
||
package com.livrets.games
|
||
|
||
import com.livrets.game.GameConfig
|
||
|
||
fun GameConfig.leaderboardId(): String = "questions_$questionCount"
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `./gradlew :sharedUI:jvmTest --tests "com.livrets.games.LeaderboardScoreTest"`
|
||
Expected: PASS (6 tests). Puis régression complète : `./gradlew :sharedUI:jvmTest` — PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add sharedUI/src/commonMain/kotlin/com/livrets/game/Question.kt \
|
||
sharedUI/src/commonMain/kotlin/com/livrets/games/LeaderboardScore.kt \
|
||
sharedUI/src/commonMain/kotlin/com/livrets/games/Leaderboards.kt \
|
||
sharedUI/src/commonTest/kotlin/com/livrets/games/LeaderboardScoreTest.kt
|
||
git commit -m "feat: score leaderboard pondéré et id de board"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Interface GamesServices et no-op
|
||
|
||
**Files:**
|
||
- Create: `sharedUI/src/commonMain/kotlin/com/livrets/games/GamesServices.kt`
|
||
|
||
**Interfaces:**
|
||
- Consumes: rien.
|
||
- Produces: `interface GamesServices { suspend fun signIn(): Boolean ; suspend fun isSignedIn(): Boolean ; suspend fun submitScore(leaderboardId: String, score: Long): Boolean ; suspend fun showLeaderboard(leaderboardId: String) }` et `object NoopGamesServices : GamesServices` — utilisés par les Tasks 3 et 4.
|
||
|
||
- [ ] **Step 1: Write the interface and the no-op implementation**
|
||
|
||
Créer `sharedUI/src/commonMain/kotlin/com/livrets/games/GamesServices.kt` :
|
||
|
||
```kotlin
|
||
package com.livrets.games
|
||
|
||
interface GamesServices {
|
||
suspend fun signIn(): Boolean
|
||
suspend fun isSignedIn(): Boolean
|
||
suspend fun submitScore(leaderboardId: String, score: Long): Boolean
|
||
suspend fun showLeaderboard(leaderboardId: String)
|
||
}
|
||
|
||
object NoopGamesServices : GamesServices {
|
||
override suspend fun signIn(): Boolean = false
|
||
override suspend fun isSignedIn(): Boolean = false
|
||
override suspend fun submitScore(leaderboardId: String, score: Long): Boolean = false
|
||
override suspend fun showLeaderboard(leaderboardId: String) {}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify compilation (les 3 cibles consomment commonMain)**
|
||
|
||
Run: `./gradlew :sharedUI:compileKotlinJvm`
|
||
Expected: BUILD SUCCESSFUL
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add sharedUI/src/commonMain/kotlin/com/livrets/games/GamesServices.kt
|
||
git commit -m "feat: interface GamesServices injectable"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Câblage UI partagée (App, Home, Results)
|
||
|
||
**Files:**
|
||
- Modify: `sharedUI/src/commonMain/kotlin/com/livrets/App.kt`
|
||
- Modify: `sharedUI/src/commonMain/kotlin/com/livrets/ui/HomeScreen.kt`
|
||
- Modify: `sharedUI/src/commonMain/kotlin/com/livrets/ui/ResultsScreen.kt`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `GamesServices`, `NoopGamesServices`, `leaderboardId()`, `leaderboardScore()` (Tasks 1-2).
|
||
- Produces: `App(filesDirPath: String, gamesServices: GamesServices = NoopGamesServices, onThemeChanged: …)` ; `HomeScreen(onPlay: (GameConfig) -> Unit, onHistory: () -> Unit, onLeaderboard: (GameConfig) -> Unit)` ; `ResultsScreen` inchangé en signature (affiche `result.leaderboardScore()`).
|
||
|
||
- [ ] **Step 1: Modifier HomeScreen (bouton Classement)**
|
||
|
||
Dans `sharedUI/src/commonMain/kotlin/com/livrets/ui/HomeScreen.kt`, remplacer la signature :
|
||
|
||
```kotlin
|
||
@Composable
|
||
fun HomeScreen(
|
||
onPlay: (GameConfig) -> Unit,
|
||
onHistory: () -> Unit,
|
||
onLeaderboard: (GameConfig) -> Unit,
|
||
) {
|
||
```
|
||
|
||
et remplacer la fin du `Column` (bouton Jouer + Historique) par :
|
||
|
||
```kotlin
|
||
Spacer(Modifier.height(32.dp))
|
||
Button(
|
||
onClick = { onPlay(GameConfig(questionCount, secondsPerQuestion, maxTable)) },
|
||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||
) {
|
||
Text("Jouer", fontSize = 20.sp)
|
||
}
|
||
TextButton(onClick = onHistory) { Text("Historique") }
|
||
TextButton(
|
||
onClick = { onLeaderboard(GameConfig(questionCount, secondsPerQuestion, maxTable)) },
|
||
) { Text("Classement") }
|
||
```
|
||
|
||
- [ ] **Step 2: Modifier ResultsScreen (ligne points classement)**
|
||
|
||
Dans `sharedUI/src/commonMain/kotlin/com/livrets/ui/ResultsScreen.kt`, ajouter l'import :
|
||
|
||
```kotlin
|
||
import com.livrets.games.leaderboardScore
|
||
```
|
||
|
||
et insérer sous le Text « Temps moyen de réponse » (ligne 55) :
|
||
|
||
```kotlin
|
||
Text(
|
||
text = "Points classement : ${result.leaderboardScore()}",
|
||
style = MaterialTheme.typography.titleMedium,
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 3: Modifier App.kt (injection, sign-in au lancement, envoi, onLeaderboard)**
|
||
|
||
Remplacer tout le contenu de `sharedUI/src/commonMain/kotlin/com/livrets/App.kt` par :
|
||
|
||
```kotlin
|
||
package com.livrets
|
||
|
||
import androidx.compose.runtime.Composable
|
||
import androidx.compose.runtime.LaunchedEffect
|
||
import androidx.compose.runtime.getValue
|
||
import androidx.compose.runtime.mutableStateOf
|
||
import androidx.compose.runtime.remember
|
||
import androidx.compose.runtime.rememberCoroutineScope
|
||
import androidx.compose.runtime.setValue
|
||
import com.livrets.data.HistoryRepository
|
||
import com.livrets.game.GameConfig
|
||
import com.livrets.game.GameResult
|
||
import com.livrets.games.GamesServices
|
||
import com.livrets.games.NoopGamesServices
|
||
import com.livrets.games.leaderboardId
|
||
import com.livrets.games.leaderboardScore
|
||
import com.livrets.theme.AppTheme
|
||
import com.livrets.ui.GameScreen
|
||
import com.livrets.ui.HistoryScreen
|
||
import com.livrets.ui.HomeScreen
|
||
import com.livrets.ui.ResultsScreen
|
||
import com.livrets.ui.SwipeBack
|
||
import kotlinx.coroutines.launch
|
||
import okio.Path.Companion.toPath
|
||
|
||
sealed interface Screen {
|
||
data object Home : Screen
|
||
data class Game(val config: GameConfig) : Screen
|
||
data class Results(val result: GameResult) : Screen
|
||
data object History : Screen
|
||
}
|
||
|
||
@Composable
|
||
fun App(
|
||
filesDirPath: String,
|
||
gamesServices: GamesServices = NoopGamesServices,
|
||
onThemeChanged: @Composable (isDark: Boolean) -> Unit = {},
|
||
) = AppTheme(onThemeChanged) {
|
||
val scope = rememberCoroutineScope()
|
||
val history = remember { HistoryRepository("$filesDirPath/history.json".toPath()) }
|
||
var screen by remember { mutableStateOf<Screen>(Screen.Home) }
|
||
|
||
LaunchedEffect(gamesServices) { gamesServices.signIn() }
|
||
|
||
val homePreview: @Composable () -> Unit = {
|
||
HomeScreen(onPlay = {}, onHistory = {}, onLeaderboard = {})
|
||
}
|
||
|
||
when (val current = screen) {
|
||
Screen.Home -> HomeScreen(
|
||
onPlay = { config -> screen = Screen.Game(config) },
|
||
onHistory = { screen = Screen.History },
|
||
onLeaderboard = { config ->
|
||
scope.launch {
|
||
if (gamesServices.isSignedIn() || gamesServices.signIn()) {
|
||
gamesServices.showLeaderboard(config.leaderboardId())
|
||
}
|
||
}
|
||
},
|
||
)
|
||
|
||
is Screen.Game -> GameScreen(
|
||
config = current.config,
|
||
onFinish = { result ->
|
||
screen = Screen.Results(result)
|
||
scope.launch { history.save(result) }
|
||
scope.launch {
|
||
val score = result.leaderboardScore()
|
||
if (score > 0 && gamesServices.isSignedIn()) {
|
||
gamesServices.submitScore(result.config.leaderboardId(), score)
|
||
}
|
||
}
|
||
},
|
||
onQuit = { screen = Screen.Home },
|
||
)
|
||
|
||
is Screen.Results -> SwipeBack(
|
||
onBack = { screen = Screen.Home },
|
||
background = homePreview,
|
||
) {
|
||
ResultsScreen(
|
||
result = current.result,
|
||
onReplay = { screen = Screen.Game(current.result.config) },
|
||
onHome = { screen = Screen.Home },
|
||
)
|
||
}
|
||
|
||
Screen.History -> SwipeBack(
|
||
onBack = { screen = Screen.Home },
|
||
background = homePreview,
|
||
) {
|
||
HistoryScreen(
|
||
history = history,
|
||
onBack = { screen = Screen.Home },
|
||
)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run régression tests + build desktop**
|
||
|
||
Run: `./gradlew :sharedUI:jvmTest`
|
||
Expected: PASS (aucune régression).
|
||
|
||
Run: `./gradlew :desktopApp:run`
|
||
Expected: l'app s'ouvre (no-op : pas d'erreur), bouton « Classement » visible sur l'accueil, ligne « Points classement : X » sur l'écran de résultats. Quitter la fenêtre.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add sharedUI/src/commonMain/kotlin/com/livrets/App.kt \
|
||
sharedUI/src/commonMain/kotlin/com/livrets/ui/HomeScreen.kt \
|
||
sharedUI/src/commonMain/kotlin/com/livrets/ui/ResultsScreen.kt
|
||
git commit -m "feat: connexion et envoi du score Play Jeux dans l'UI partagée"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Implémentation Android PlayGamesServices
|
||
|
||
**Files:**
|
||
- Modify: `gradle/libs.versions.toml`
|
||
- Modify: `androidApp/build.gradle.kts`
|
||
- Create: `androidApp/src/main/kotlin/com/livrets/androidApp/PlayGamesServices.kt`
|
||
- Modify: `androidApp/src/main/kotlin/com/livrets/androidApp/AppActivity.kt`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `GamesServices` (Task 2 — mêmes signatures exactes).
|
||
- Produces: `PlayGamesServices(activity: Activity, launchIntent: (Intent) -> Unit) : GamesServices` — instancié dans `AppActivity`.
|
||
|
||
- [ ] **Step 1: Ajouter les dépendances au catalogue**
|
||
|
||
Dans `gradle/libs.versions.toml` :
|
||
|
||
- Dans la section `[versions]`, ajouter :
|
||
|
||
```toml
|
||
gms-games-v2 = "22.0.0"
|
||
```
|
||
|
||
- Dans la section `[libraries]`, ajouter :
|
||
|
||
```toml
|
||
gms-games-v2 = { module = "com.google.android.gms:play-services-games-v2", version.ref = "gms-games-v2" }
|
||
kotlinx-coroutines-play-services = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "kotlinx-coroutines" }
|
||
```
|
||
|
||
- [ ] **Step 2: Déclarer les dépendances dans androidApp**
|
||
|
||
Dans `androidApp/build.gradle.kts`, remplacer le bloc `dependencies` par :
|
||
|
||
```kotlin
|
||
dependencies {
|
||
implementation(project(":sharedUI"))
|
||
implementation(libs.androidx.activityCompose)
|
||
implementation(libs.gms.games.v2)
|
||
implementation(libs.kotlinx.coroutines.play.services)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Écrire PlayGamesServices**
|
||
|
||
Créer `androidApp/src/main/kotlin/com/livrets/androidApp/PlayGamesServices.kt` :
|
||
|
||
```kotlin
|
||
package com.livrets.androidApp
|
||
|
||
import android.app.Activity
|
||
import android.content.Intent
|
||
import com.google.android.gms.games.PlayGames
|
||
import com.livrets.games.GamesServices
|
||
import kotlinx.coroutines.Dispatchers
|
||
import kotlinx.coroutines.tasks.await
|
||
import kotlinx.coroutines.withContext
|
||
|
||
class PlayGamesServices(
|
||
private val activity: Activity,
|
||
private val launchIntent: (Intent) -> Unit,
|
||
) : GamesServices {
|
||
|
||
override suspend fun signIn(): Boolean = withContext(Dispatchers.IO) {
|
||
runCatching {
|
||
PlayGames.getGamesSignInClient(activity).signIn().await().isAuthenticated
|
||
}.getOrDefault(false)
|
||
}
|
||
|
||
override suspend fun isSignedIn(): Boolean = withContext(Dispatchers.IO) {
|
||
runCatching {
|
||
PlayGames.getGamesSignInClient(activity).isAuthenticated().await().isAuthenticated
|
||
}.getOrDefault(false)
|
||
}
|
||
|
||
override suspend fun submitScore(leaderboardId: String, score: Long): Boolean =
|
||
withContext(Dispatchers.IO) {
|
||
runCatching {
|
||
PlayGames.getLeaderboardsClient(activity)
|
||
.submitScoreImmediate(leaderboardId, score)
|
||
.await()
|
||
true
|
||
}.getOrDefault(false)
|
||
}
|
||
|
||
override suspend fun showLeaderboard(leaderboardId: String) {
|
||
val intent = withContext(Dispatchers.IO) {
|
||
runCatching {
|
||
PlayGames.getLeaderboardsClient(activity)
|
||
.getLeaderboardIntent(leaderboardId)
|
||
.await()
|
||
}.getOrNull()
|
||
} ?: return
|
||
launchIntent(intent)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Câbler dans AppActivity**
|
||
|
||
Remplacer tout le contenu de `androidApp/src/main/kotlin/com/livrets/androidApp/AppActivity.kt` par :
|
||
|
||
```kotlin
|
||
package com.livrets.androidApp
|
||
|
||
import android.app.Activity
|
||
import android.os.Bundle
|
||
import androidx.activity.ComponentActivity
|
||
import androidx.activity.compose.setContent
|
||
import androidx.activity.enableEdgeToEdge
|
||
import androidx.activity.result.contract.ActivityResultContracts
|
||
import androidx.compose.runtime.Composable
|
||
import androidx.compose.runtime.LaunchedEffect
|
||
import androidx.compose.ui.platform.LocalView
|
||
import androidx.core.view.WindowInsetsControllerCompat
|
||
import com.livrets.App
|
||
|
||
class AppActivity : ComponentActivity() {
|
||
|
||
private val leaderboardLauncher =
|
||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { }
|
||
|
||
override fun onCreate(savedInstanceState: Bundle?) {
|
||
super.onCreate(savedInstanceState)
|
||
enableEdgeToEdge()
|
||
val gamesServices = PlayGamesServices(this) { intent -> leaderboardLauncher.launch(intent) }
|
||
setContent {
|
||
App(
|
||
filesDirPath = filesDir.absolutePath,
|
||
gamesServices = gamesServices,
|
||
onThemeChanged = { ThemeChanged(it) },
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
@Composable
|
||
private fun ThemeChanged(isDark: Boolean) {
|
||
val view = LocalView.current
|
||
LaunchedEffect(isDark) {
|
||
val window = (view.context as Activity).window
|
||
WindowInsetsControllerCompat(window, window.decorView).apply {
|
||
isAppearanceLightStatusBars = isDark
|
||
isAppearanceLightNavigationBars = isDark
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Verify compilation APK debug**
|
||
|
||
Run: `./gradlew :androidApp:assembleDebug`
|
||
Expected: BUILD SUCCESSFUL (la connexion échouera à l'exécution tant que la console n'est pas configurée — comportement silencieux attendu).
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add gradle/libs.versions.toml androidApp/build.gradle.kts \
|
||
androidApp/src/main/kotlin/com/livrets/androidApp/PlayGamesServices.kt \
|
||
androidApp/src/main/kotlin/com/livrets/androidApp/AppActivity.kt
|
||
git commit -m "feat: implémentation Play Games Services v2 sur Android"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: README (prérequis console) + build complet
|
||
|
||
**Files:**
|
||
- Modify: `README.MD`
|
||
|
||
**Interfaces:**
|
||
- Consumes: rien.
|
||
- Produces: documentation des prérequis Play Console (spec § 7) et de la checklist de vérification manuelle.
|
||
|
||
- [ ] **Step 1: Documenter les prérequis dans le README**
|
||
|
||
Dans `README.MD`, ajouter cette section après la section « ### macOS desktop » (avant « ## Tests ») :
|
||
|
||
```markdown
|
||
## Play Jeux Services (Android)
|
||
|
||
Connexion silencieuse au lancement, envoi automatique du score en fin de partie et
|
||
bouton « Classement » sur l'accueil (UI native Play Jeux). Hors connexion ou sans
|
||
configuration console, tout échoue silencieusement : le jeu reste pleinement jouable.
|
||
|
||
Score soumis : `round((somme des produits réussis) × 30 ÷ secondes par question)`.
|
||
|
||
Prérequis manuels dans [Google Play Console](https://play.google.com/console) :
|
||
|
||
1. Créer l'app (package `com.livrets.androidApp`).
|
||
2. Jouer → Play Jeux Services : créer le jeu, lier l'app Android, ajouter les
|
||
empreintes SHA-1 debug et release.
|
||
3. Créer les 10 leaderboards avec les IDs `questions_5` … `questions_50`
|
||
(« 5 questions » … « 50 questions »).
|
||
4. Ajouter des testeurs Play Jeux Services pour valider la connexion en dev.
|
||
```
|
||
|
||
- [ ] **Step 2: Build complet (régression 3 cibles)**
|
||
|
||
Run: `./gradlew build`
|
||
Expected: BUILD SUCCESSFUL.
|
||
|
||
- [ ] **Step 3: Vérification manuelle Android (si appareil + console configurés)**
|
||
|
||
Checklist sur appareil Android avec compte Play (testeur PGS requis) :
|
||
|
||
```bash
|
||
./gradlew :androidApp:installDebug
|
||
```
|
||
|
||
1. Lancement : aucune UI de connexion bloquante.
|
||
2. Jouer une partie → écran résultats : ligne « Points classement » cohérente avec la formule.
|
||
3. Accueil → « Classement » : l'UI Play Jeux s'ouvre sur le board correspondant au nombre de questions sélectionné.
|
||
4. Le score apparaît dans le board Play Jeux.
|
||
5. Sans compte / hors connexion : l'app se comporte comme avant (aucun crash, aucune erreur visible).
|
||
|
||
Si la console n'est pas configurée, noter le résultat et passer à l'étape suivante (le comportement silencieux fait partie du spec).
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add README.MD
|
||
git commit -m "docs: prérequis Play Console pour Play Jeux Services"
|
||
```
|