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>
100 lines
3.8 KiB
Swift
100 lines
3.8 KiB
Swift
// 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")
|
||
}
|
||
}
|