La Live Activity restait affichée après la fin de séance

La montre n'annonçait pas la fin : le flux de samples s'arrêtait, et
l'iPhone ne pouvait pas distinguer « séance terminée » de « montre hors de
portée ». La Live Activity restait donc figée sur la dernière FC reçue —
un cœur à 74 bpm en permanence dans la Dynamic Island.

Trois causes cumulées, toutes corrigées :

1. Aucun message de fin. WorkoutManager envoie maintenant `liveEnded` à
   l'arrêt de la session HealthKit, en cas d'échec, et en mode test.
2. Le seul chemin de terminaison côté iPhone était le watchdog de
   LiveStore, un Timer du main runloop — qui ne tourne pas quand l'app est
   suspendue en arrière-plan, c'est-à-dire pendant toute la séance.
   `endLive()` termine désormais l'activité dès réception du message.
3. `dismissalPolicy: .default` laissait la bannière ~4 h APRÈS la fin.
   Passé en `.immediate`.

Filet de sécurité : `adoptExisting()` au lancement reprend la main sur une
activité orpheline (app tuée pendant une séance, `current` perdu) et
termine celles de plus de 6 h.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Bettinelli
2026-08-11 12:50:32 +00:00
parent 051c8e8ff4
commit 5e9b4a3df6
5 changed files with 99 additions and 7 deletions

View File

@@ -48,6 +48,35 @@ final class ConnectivityManager: NSObject, ObservableObject {
}
}
/// Signale la FIN de séance à l'iPhone.
///
/// Sans ce message, l'iPhone ne peut pas distinguer « séance terminée » de
/// « montre hors de portée » : le flux de samples s'arrête dans les deux
/// cas. Sa Live Activity restait alors figée sur la dernière FC reçue.
/// Les samples encore en attente partent AVANT la fin (flushLocked) pour
/// que l'ordre d'arrivée reste cohérent.
func sendEnded(reason: String = "user") {
queue.async {
let session = WCSession.default
guard session.activationState == .activated else { return }
self.flushLocked()
let payload: [String: Any] = [
"liveEnded": true,
"reason": reason,
"ts": Date().timeIntervalSince1970,
]
if session.isReachable {
session.sendMessage(payload, replyHandler: nil) { _ in
// Échec temps réel : livraison différée garantie.
session.transferUserInfo(payload)
}
} else {
session.transferUserInfo(payload)
}
connLog.info("fin de séance signalée à l'iPhone (reason=\(reason, privacy: .public))")
}
}
/// Envoie l'état COMPLET de la routine du jour vers l'iPhone, qui le POSTe
/// au serveur. Retourne `false` si rien n'a pu partir (l'appelant affiche
/// alors un bouton « Renvoyer »).