Files
coach-ios/ios/App/App/LiveActivityManager.swift
Sylvain Bettinelli 5e9b4a3df6 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>
2026-08-11 12:50:32 +00:00

100 lines
3.8 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// LiveActivityManager.swift
// Wrapper main-actor autour d'ActivityKit pour la Live Activity / Dynamic
// Island du live workout. Appelé par LiveStore à chaque transition / sample.
// API ActivityKit vérifiée sur iOS 26.5 SDK swiftinterface :
// Activity.request(attributes:, content: ActivityContent<...>, pushType:)
// activity.update(_ content: ActivityContent<...>) async
// activity.end(_ content: ActivityContent<...>?, dismissalPolicy:) async
// ActivityAuthorizationInfo().areActivitiesEnabled
import ActivityKit
import Foundation
import os
@available(iOS 16.2, *)
@MainActor
final class LiveActivityManager {
static let shared = LiveActivityManager()
private var current: Activity<CoachLiveActivityAttributes>?
private static let log = Logger(subsystem: "ch.hypnotruck.coach", category: "live-activity")
private init() {}
/// Reprend la main sur une activité laissée par une exécution précédente.
///
/// Si l'app iPhone est tuée pendant une séance, `current` repart à nil au
/// lancement suivant : `end()` devenait un no-op et l'activité restait
/// affichée indéfiniment. À appeler une fois au démarrage.
/// Au-delà de 6 h, aucune séance n'est plausible : on termine.
func adoptExisting() {
for activity in Activity<CoachLiveActivityAttributes>.activities {
if Date().timeIntervalSince(activity.attributes.startedAt) > 6 * 3600 {
Task { await activity.end(nil, dismissalPolicy: .immediate) }
Self.log.info("Live Activity orpheline (>6h) terminée")
} else if current == nil {
current = activity
Self.log.info("Live Activity existante adoptée")
}
}
}
private var isAuthorized: Bool {
ActivityAuthorizationInfo().areActivitiesEnabled
}
/// Démarre l'activité si pas déjà en cours. Si déjà en cours, met à jour.
func start(label: String,
symbol: String,
state: CoachLiveActivityAttributes.ContentState) {
if current != nil {
update(state: state)
return
}
guard isAuthorized else {
Self.log.warning("Live Activities not authorized (user disabled in Settings)")
return
}
let attrs = CoachLiveActivityAttributes(
activityLabel: label,
activitySymbol: symbol,
startedAt: Date()
)
let content = ActivityContent(
state: state,
staleDate: Date().addingTimeInterval(60)
)
do {
current = try Activity.request(
attributes: attrs,
content: content,
pushType: nil
)
Self.log.info("Live Activity started: \(label, privacy: .public)")
} catch {
Self.log.error("Failed to start Live Activity: \(error.localizedDescription, privacy: .public)")
}
}
/// Met à jour l'activité en cours (no-op si pas démarrée).
func update(state: CoachLiveActivityAttributes.ContentState) {
guard let activity = current else { return }
let content = ActivityContent(
state: state,
staleDate: Date().addingTimeInterval(60)
)
Task { await activity.update(content) }
}
/// Termine l'activité et la retire de l'écran immédiatement.
/// `.default` laisse la bannière ~4 h après la fin : la séance est
/// terminée mais le cœur reste affiché avec la dernière FC reçue, ce qui se
/// lit comme une séance encore en cours. `.immediate` est le seul
/// comportement correct ici.
func end() {
guard let activity = current else { return }
current = nil
Task { await activity.end(nil, dismissalPolicy: .immediate) }
Self.log.info("Live Activity ended")
}
}