Files
GameTime/.ideai/tickets/109/carnet.md
Blomios 917777e18b chore(wip): consolidation intermédiaire multi-tickets (sprints Statistiques, UI, Bug resolution, Serveur-client)
Regroupe l'état de travail en cours réalisé dans un même worktree sur
plusieurs tickets/sprints (#85, #136, #145, #155-160, #162-164),
mélangeant des tickets QA et inProgress. Ne constitue pas une feature
terminée : commit de sauvegarde avant triage/split par ticket en
branches feature/* dédiées. Exclut les dossiers d'environnement de
build locaux et le heap dump parasite (.gitignore mis à jour).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 16:48:54 +02:00

12 KiB

issueRef, version, updatedBy, updatedAt
issueRef version updatedBy updatedAt
#109 5
kind agent_id
agent 57695b92-24d0-4876-837c-76116e70a6ae
1785134675243

#109 — Cadrage technique notification de séance Android (Architect, 2026-07-26)

Statut : cadrage exploitable, prêt pour DevFrontend, pas de blocage. Basé sur le cadrage UX #108 et l'existant réel (WatchCompanionForegroundService.kt, WatchSessionProjectionProjector, cf. gametime-watch-companion-implementation).

Constat sur l'existant

Un ForegroundService tourne déjà pendant toute séance active (android/app/src/main/kotlin/com/gametime/app/watch/WatchCompanionForegroundService.kt), démarré/arrêté par WatchWearDataLayerAdapter._syncForegroundService sur simple base de phase != noActiveSessionindépendamment du pairing montre. Mais ce service est aujourd'hui verrouillé sur la feature montre : channel gametime_watch_companion, notification statique créée une seule fois dans onCreate() (titre/texte fixes « GameTime » / « Séance en cours »), NOTIFICATION_ID = 91, aucune méthode de mise à jour de contenu, foregroundServiceType="connectedDevice|dataSync".

Décision : ne pas réutiliser ce service tel quel pour #102. On crée un second foreground service indépendant, propre à la notification de séance téléphone, avec son propre canal et son propre cycle de vie. Raison (ISP appliqué aux adapters Android) : coupler la notification "résumé de séance" au service montre ferait dépendre une feature du couplage montre/pairing implicite, alors que la notification doit exister avec ou sans montre appairée, et une évolution du companion montre ne doit jamais risquer de casser la notification (et réciproquement). Android autorise plusieurs foreground services concurrents sans conflit.

Contrat côté domaine/application

Pas de nouvel état domaine : la notification est un présentateur supplémentaire d'un état déjà calculé. Réutiliser tel quel le flux existant WatchProjectionSource.projections (déjà émis à chaque changement pertinent : série, pause, repos, tick de chrono score) plutôt que dupliquer la logique de sélection du "chrono dominant" (priorité chrono > reps/score > étape, déjà implémentée dans WatchSessionProjectionProjector pour produire dominantTimer).

  • Nouveau port application :
abstract interface class SessionNotificationGateway {
  Future<void> show(SessionNotificationContent content);
  Future<void> clear();
}
  • Nouveau DTO pur (application layer) :
class SessionNotificationContent {
  final String title;       // nom exercice / "Repos" / exercice (pause)
  final String primaryLine; // mm:ss, "Série 2/4 · 8 reps", décompte repos, "En pause · ..."
  final String? secondaryLine; // "Programme 1/2 · Exercice 3/8", null si compact only
}
  • Nouvelle fonction pure (testable sans plateforme) : SessionNotificationContent buildSessionNotificationContent(WatchSessionProjection projection) — mapping direct des états du tableau UX #108 (série+chrono, série reps/score, repos, pause, terminée→clear()). Réutilise dominantTimer/phase/indices déjà présents dans WatchSessionProjection, pas de nouveau champ requis sur ce DTO existant.
  • Nouveau coordinateur application SessionNotificationCoordinator : s'abonne à WatchProjectionSource.projections, appelle buildSessionNotificationContent puis gateway.show(...), appelle gateway.clear() sur phase == noActiveSession. Ne dépend d'aucun état montre/pairing — seul le flux de projection (déjà session-only) est consommé.

Règle de rafraîchissement (traduction concrète de l'UX)

  • show() appelé à chaque émission de projection représentant un changement structurel (bump de révision) : changement de série/étape/phase/pause.
  • En plus, si dominantTimer.runState == running (chrono actif ou repos), un tick local 1s recalcule primaryLine depuis referenceEpochMs/accumulatedMs/targetMs déjà présents sur WatchTimerProjection — tick strictement local au coordinateur de notification, ne déclenche jamais de bump de révision ni de republication vers la montre (éviter tout couplage/chatter croisé entre les deux features).
  • Sinon (pas de chrono affiché), aucune mise à jour périodique — conforme à l'exigence UX "pas de rafraîchissement inutile".
  • Invariant robustesse : un échec de show()/clear() ne doit jamais remonter d'exception dans le flux domaine/session (fire-and-forget côté notification) — une panne de notification ne doit jamais interrompre une séance en cours.

Contrat côté adapter Android (nouveau)

  • Nouveau service Kotlin, ex. android/app/src/main/kotlin/com/gametime/app/session/SessionStatusForegroundService.kt, distinct de WatchCompanionForegroundService.
  • Nouveau canal gametime_session_status, IMPORTANCE_LOW (pas de son, cohérent avec les mises à jour fréquentes de chrono), NOTIFICATION_ID distinct (ex. 92, à ne pas réutiliser 91).
  • Contrairement au service montre, celui-ci doit exposer une méthode de mise à jour de contenu (pas de notification statique créée une fois) — updateNotification(title, primaryLine, secondaryLine?) appelée à chaque show().
  • foregroundServiceType : ni dataSync ni connectedDevice ne conviennent sémantiquement (ce n'est ni de la synchro de données ni un device connecté). Sur SDK 36, utiliser specialUse avec la propriété manifeste android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE (valeur libre du type "workout-session-status"). L'app n'étant pas distribuée sur le Play Store (APK signé debug, cf. gametime-android-release-networking-and-apk-build), la contrainte de justification specialUse en review Play ne s'applique pas ; seule la déclaration manifeste est requise par l'OS.
  • Permission POST_NOTIFICATIONS déjà déclarée dans le manifest (réutilisée pour le canal montre) — pas de nouvelle permission requise. Si l'utilisateur refuse la permission notification, le foreground service démarre quand même (contrat Android standard : startForeground exige une Notification, affichée ou non selon permission) — aucune gestion spécifique à ajouter, ne jamais bloquer le démarrage de séance sur ce refus.

Tests

  • buildSessionNotificationContent : 100% unit-testable en Dart pur, mêmes conventions que test/application/watch_companion_projection_test.dart — couvrir les 5 états du tableau UX #108 (série+chrono, série reps/score, repos, pause, fin→clear).
  • SessionNotificationCoordinator : test avec StreamController fake, vérifier show() sur changement structurel et sur tick actif uniquement, clear() sur fin de séance, absence d'appel superflu si aucun chrono actif.
  • Service Kotlin : non testable en sandbox (cohérent avec la contrainte déjà connue pour WatchCompanionForegroundService, cf. gametime-watch-companion-implementation) — validation manuelle on-device requise (affichage réel, tick, disparition en fin de séance, comportement écran verrouillé).

Hors périmètre (aligné UX #108)

Pas d'action bouton dans la notification (pas de PendingIntent Pause/Suivant) — tap ouvre l'app sur l'écran d'exécution en cours (Intent standard PendingIntent.getActivity vers l'activité principale, aucun deep-link spécifique nécessaire pour le MVP).

Explicitement hors scope : iOS. Le contrat SessionNotificationGateway est un port application générique (compatible iOS en théorie), mais aucun adapter iOS n'est cadré ni demandé ici — seul l'adapter Android (MethodChannelSessionNotificationGateway + plugin Kotlin) est dans le périmètre de #102/#110. Si iOS est demandé un jour, c'est un nouveau ticket d'adapter, pas une révision de ce contrat.


Audit Architect — implémentation constatée dans le worktree (2026-07-27)

Statut mis à jour : implémenté conformément au cadrage ci-dessus, un garde-fou à fermer avant merge/QA.

Vérification du code réel (working tree non commité, branche feature/ticket91-wear-os-watch-sync) : SessionNotificationCoordinator/buildSessionNotificationContent (lib/application/session_notification_use_cases.dart), MethodChannelSessionNotificationGateway (lib/infrastructure/session_notification/), SessionStatusForegroundService.kt/SessionNotificationPlugin.kt (android/app/src/main/kotlin/com/gametime/app/session/) sont tous présents et conformes point par point au contrat cadré : consommation de WatchCompanionProjectionUseCases.projections (pas d'état dupliqué), tick 1s uniquement si timer actif, clear() sur phase == noActiveSession || deviceSessionId.isEmpty, appels gateway unawaited(...).catchError((_) {}) (fire-and-forget confirmé), canal gametime_session_status/NOTIFICATION_ID 92 distinct du service montre, foregroundServiceType="specialUse" avec PROPERTY_SPECIAL_USE_FGS_SUBTYPE comme cadré. Wiring dans app_bootstrap.dart (start juste après watchWearDataLayerAdapter.start(), dispose en premier).

Garde-fou à fermer avant de considérer le lot terminé : le manifest déclare bien POST_NOTIFICATIONS, mais aucun code (Dart ou Kotlin) ne demande cette permission à l'exécution nulle part dans l'app — ni pour ce nouveau canal, ni pour le canal montre pré-existant. Sur Android 13+ (API 33+, cohérent avec la cible SDK 36), sans cette demande explicite le service démarre normalement (exception foreground service) mais la notification peut ne jamais s'afficher à l'utilisateur, silencieusement. Contrat à ajouter pour DevFrontend : demander POST_NOTIFICATIONS une seule fois, paresseusement, au premier démarrage de séance (jamais un écran dédié, jamais bloquant — cohérent avec gametime-online-layer-philosophy appliqué par analogie aux permissions système) ; en cas de refus, ne rien tenter de plus, ne jamais redemander en boucle. C'est un gap transverse aux deux notifications (montre + séance), pas spécifique à l'une des deux — à traiter une seule fois pour les deux canaux.

Tests couvrant buildSessionNotificationContent déjà présents (test/application/session_notification_use_cases_test.dart) ; SessionNotificationCoordinator lui-même (start/stop/tick/gateway failure) n'est pas encore testé — à ajouter avant merge, cf. stratégie de test déjà cadrée ci-dessus.


Vérification Architect de clôture (2026-07-27, second passage)

Statut final : conforme, garde-fou permission fermé, un seul gap résiduel connu (non bloquant pour le cadrage, bloquant pour le merge).

  • Le garde-fou POST_NOTIFICATIONS signalé ci-dessus est résolu : SessionNotificationPlugin.kt (android/app/src/main/kotlin/com/gametime/app/session/SessionNotificationPlugin.kt) appelle requestPostNotificationsPermissionIfNeeded() dans show(), guardé par un flag notificationPermissionRequested (une seule demande, jamais de boucle), SDK_INT >= TIRAMISU uniquement, pas d'écran dédié. La permission POST_NOTIFICATIONS est globale à l'app (pas par canal) donc cette unique demande couvre aussi le canal montre pré-existant — le gap transverse est clos par un seul point d'appel, conforme à la recommandation.
  • Gap résiduel inchangé : SessionNotificationCoordinator n'a toujours pas de test dédié (test/application/session_notification_use_cases_test.dart ne couvre que buildSessionNotificationContent, 5 cas). À ajouter avant merge (StreamController fake : show() sur changement structurel + sur tick actif, clear() en fin de séance, non-appel si pas de chrono actif) — cf. stratégie déjà cadrée plus haut. Ceci est un standard de qualité (couverture), pas une ambiguïté de contrat : n'importe qui connaissant SessionNotificationCoordinator peut écrire ce test sans redemander de cadrage.

Réponse à la question "DevFrontend peut-il implémenter sans autre clarification ?" : oui. Le contrat est complet et déjà appliqué à l'identique dans le code existant. Il ne reste aucune décision d'architecture ouverte pour #102/#110 — seulement une tâche de complétion de test (coordinateur) avant que le lot passe en QA (#111).