From 21d39f2e98fd6e77f7d4cc1ca7ff968b3cf2b5e0 Mon Sep 17 00:00:00 2001 From: Sylvain Bettinelli Date: Tue, 11 Aug 2026 13:25:14 +0000 Subject: [PATCH] =?UTF-8?q?Piloter=20la=20s=C3=A9ance=20depuis=20la=20Live?= =?UTF-8?q?=20Activity=20(Pause=20/=20Terminer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comme l'app Exercice : deux boutons sur la bannière de l'écran verrouillé et dans l'île dépliée. iOS n'expose aucun geste de balayage pour révéler des actions — depuis iOS 17, ce sont des boutons intégrés (App Intents), et c'est le seul mécanisme public. - Intents `CoachTogglePauseIntent` / `CoachEndWorkoutIntent`, conformes à LiveActivityIntent (sans quoi `perform()` n'est jamais appelé). - Ils vivent dans CoachLiveActivityAttributes.swift, seul fichier déjà membre des deux targets : un fichier neuf imposerait une manip Target Membership dans Xcode, source d'erreurs répétées ici. - Commandes relayées à la montre par WCSession, avec repli transferUserInfo : isReachable retombe à false quand la séance tourne en arrière-plan profond, la commande est alors différée au réveil de l'app montre. - L'état de pause remonte depuis HKWorkoutSession (seule source fiable : la montre peut mettre en pause d'elle-même) et bascule le libellé du bouton. - Le watchdog de LiveStore est neutralisé pendant la pause : sans ça, l'absence de samples aurait affiché « connexion perdue » puis terminé l'activité. - L'app watchOS gagne le bouton Pause qui lui manquait, aligné sur le même état. Co-Authored-By: Claude Opus 5 (1M context) --- docs/live-activity-runbook-mac.md | 30 ++++++ ios/App/App/CoachLiveBridge.swift | 9 ++ ios/App/App/LiveStore.swift | 22 ++++- .../CoachLiveActivityAttributes.swift | 96 ++++++++++++++++++- .../CoachLiveActivityWidget.swift | 55 ++++++++--- ios/App/CoachWatch/ConnectivityManager.swift | 39 ++++++++ ios/App/CoachWatch/ContentView.swift | 12 +++ ios/App/CoachWatch/WorkoutManager.swift | 35 ++++++- 8 files changed, 283 insertions(+), 15 deletions(-) diff --git a/docs/live-activity-runbook-mac.md b/docs/live-activity-runbook-mac.md index f1a0d7c..a0b4ca1 100644 --- a/docs/live-activity-runbook-mac.md +++ b/docs/live-activity-runbook-mac.md @@ -91,6 +91,36 @@ Il vient de l'ancien build. Il partira soit au premier `liveEnded` reçu (la nouvelle app reprend la main dessus via `adoptExisting()`), soit après 6 h, soit tout de suite si tu le fermes à la main (appui long → fermer). +## Étape 4bis — Les boutons Pause / Terminer *(build du 2026-08-11, second lot)* + +La Live Activity porte maintenant deux boutons, comme l'app Exercice : +**Pause / Reprendre** et **Terminer**. Ils apparaissent sur la bannière de +l'écran verrouillé et dans l'île dynamique **dépliée** (appui long sur l'île) — +pas en mode compact, où iOS n'accepte aucun bouton. + +Aucune manipulation Xcode : les intents vivent dans +`CoachLiveActivityAttributes.swift`, déjà membre des deux targets. + +À tester, séance en cours : + +1. **Appui long sur l'île** → les deux boutons apparaissent +2. **Pause** → la séance se met en pause sur la montre, le bouton devient + **Reprendre**, et la page `/live` ne bascule **pas** en « Connexion perdue » + (le watchdog est neutralisé pendant la pause) +3. **Reprendre** → la FC repart +4. **Terminer** → la séance s'arrête sur la montre et la Live Activity + disparaît immédiatement + +⚠️ **Limite Apple, à connaître avant de crier au bug** : `WCSession.isReachable` +retombe à false quand l'app watchOS tourne sa séance en arrière-plan profond +(poignet baissé, écran éteint). La commande bascule alors sur +`transferUserInfo` : elle est **garantie mais différée**, donc elle s'applique +au réveil de l'app montre et non à la seconde. Poignet levé, c'est immédiat. +Aucune API publique ne fait mieux. + +Détail mineur : en **mode test** (FC simulée), la pause bascule l'état mais la +simulation continue d'émettre des samples. Sans effet sur une vraie séance. + ## Étape 5 — TestFlight *(facultatif)* Rien de spécifique ici : c'est la procédure habituelle, étapes 7 à 9 de diff --git a/ios/App/App/CoachLiveBridge.swift b/ios/App/App/CoachLiveBridge.swift index 178366e..859f981 100644 --- a/ios/App/App/CoachLiveBridge.swift +++ b/ios/App/App/CoachLiveBridge.swift @@ -135,6 +135,15 @@ extension CoachLiveBridgePlugin: WCSessionDelegate { ) return true } + // Pause / reprise : pilote le libellé du bouton de la Live Activity. + if let paused = message["paused"] as? Bool { + liveLog.info("état pause reçu de la Watch : \(paused, privacy: .public)") + DispatchQueue.main.async { [weak self] in + self?.notifyListeners("livePaused", data: message) + } + LiveStore.shared.setPaused(paused) + return true + } // Fin de séance annoncée par la montre. Sans ce signal, l'arrêt du flux // est indistinguable d'une perte de portée : la Live Activity restait // figée sur la dernière FC. diff --git a/ios/App/App/LiveStore.swift b/ios/App/App/LiveStore.swift index c93fc2e..576687d 100644 --- a/ios/App/App/LiveStore.swift +++ b/ios/App/App/LiveStore.swift @@ -24,6 +24,7 @@ final class LiveStore: ObservableObject { @Published var activityType: String? = nil @Published var status: Status = .waiting @Published var lastSampleAt: Date? = nil + @Published var isPaused: Bool = false private var watchdog: Timer? private let lostThreshold: TimeInterval = 8 @@ -35,6 +36,10 @@ final class LiveStore: ObservableObject { Task { @MainActor [weak self] in guard let self else { return } guard let ts = self.lastSampleAt else { return } + // En pause, HealthKit cesse de collecter : l'absence de sample + // est normale et ne doit ni afficher « connexion perdue », ni + // finir par terminer la Live Activity. + guard !self.isPaused else { return } let sinceLast = Date().timeIntervalSince(ts) if self.status == .live, sinceLast > self.lostThreshold { // Transition live -> lost : signale à la Live Activity @@ -95,6 +100,19 @@ final class LiveStore: ObservableObject { } } + /// Pause / reprise annoncée par la montre. Met à jour la Live Activity pour + /// que son bouton bascule (Pause ↔ Reprendre). + /// nonisolated : appelé depuis un callback WCSession, hors main. + nonisolated func setPaused(_ paused: Bool) { + Task { @MainActor [weak self] in + guard let self else { return } + self.isPaused = paused + if #available(iOS 16.2, *) { + LiveActivityManager.shared.update(state: self.currentState(isLive: self.status == .live)) + } + } + } + /// Fin de séance annoncée par la montre (`liveEnded`). Termine la Live /// Activity tout de suite, sans attendre le watchdog — celui-ci repose sur /// un Timer du main runloop, qui ne tourne pas quand l'app iPhone est @@ -106,6 +124,7 @@ final class LiveStore: ObservableObject { self.status = .waiting self.lostSince = nil self.lastSampleAt = nil + self.isPaused = false if #available(iOS 16.2, *) { LiveActivityManager.shared.end() } @@ -119,7 +138,8 @@ final class LiveStore: ObservableObject { activeEnergyKcal: activeEnergyKcal, distanceMeters: distanceMeters, elapsedSec: elapsedSec, - isLive: isLive + isLive: isLive, + isPaused: isPaused ) } diff --git a/ios/App/CoachLiveActivity/CoachLiveActivityAttributes.swift b/ios/App/CoachLiveActivity/CoachLiveActivityAttributes.swift index 0e9e29e..9b5906a 100644 --- a/ios/App/CoachLiveActivity/CoachLiveActivityAttributes.swift +++ b/ios/App/CoachLiveActivity/CoachLiveActivityAttributes.swift @@ -1,9 +1,20 @@ // CoachLiveActivityAttributes.swift // Modèle partagé App ↔ Widget Extension (membre des 2 targets). -// Définit ce que la Live Activity / Dynamic Island affiche. +// Définit ce que la Live Activity / Dynamic Island affiche, et les commandes +// que ses boutons envoient à la montre. +// +// ⚠️ Les intents et CoachWatchCommand vivent ICI plutôt que dans un fichier +// dédié pour une raison pratique : ils doivent appartenir aux DEUX targets +// (l'extension compile les boutons, l'app exécute leur `perform()`), et ce +// fichier est le seul déjà câblé ainsi. Un nouveau fichier imposerait une +// manipulation de Target Membership dans Xcode — source d'erreurs répétées +// sur ce projet (cf. docs/SESSION-XCODE.md). import ActivityKit +import AppIntents import Foundation +import WatchConnectivity +import os @available(iOS 16.2, *) struct CoachLiveActivityAttributes: ActivityAttributes { @@ -14,6 +25,10 @@ struct CoachLiveActivityAttributes: ActivityAttributes { var distanceMeters: Double var elapsedSec: TimeInterval var isLive: Bool // true = direct ; false = perdu/en pause + /// Séance mise en pause sur la montre. Pilote le libellé du bouton + /// (Pause ↔ Reprendre). La valeur par défaut ne sert qu'aux sites de + /// construction : le `init(from:)` synthétisé n'en tient pas compte. + var isPaused: Bool = false } /// Données fixes (set au start, non modifiables). @@ -21,3 +36,82 @@ struct CoachLiveActivityAttributes: ActivityAttributes { var activitySymbol: String // SF Symbol (figure.run, bicycle, figure.walk, dumbbell) var startedAt: Date } + +// MARK: - Commandes vers la montre + +/// Envoie une commande de pilotage de séance (pause / reprise / fin) à l'app +/// watchOS via WatchConnectivity. +/// +/// ⚠️ Limite Apple connue : `isReachable` retombe à false quand l'app watchOS +/// tourne sa séance en arrière-plan profond (poignet baissé). `sendMessage` +/// échoue alors. On retombe sur `transferUserInfo`, dont la livraison est +/// garantie mais **différée** : la commande s'applique au réveil de l'app +/// montre, pas à la seconde près. Rien dans les API publiques ne permet mieux. +enum CoachWatchCommand { + static let pause = "pause" + static let resume = "resume" + static let end = "end" + + private static let log = Logger(subsystem: "ch.hypnotruck.coach", category: "watch-command") + + static func send(_ command: String) { + guard WCSession.isSupported() else { return } + let session = WCSession.default + let payload: [String: Any] = [ + "workoutCommand": command, + "ts": Date().timeIntervalSince1970, + ] + // L'intent peut réveiller l'app alors que le plugin n'a pas encore + // activé la session : dans ce cas, seul transferUserInfo est possible + // (il accepte d'être appelé avant activation, la file est persistée). + guard session.activationState == .activated else { + session.transferUserInfo(payload) + log.info("commande \(command, privacy: .public) mise en file (session non activée)") + return + } + if session.isReachable { + session.sendMessage(payload, replyHandler: nil) { _ in + session.transferUserInfo(payload) + } + } else { + session.transferUserInfo(payload) + } + log.info("commande \(command, privacy: .public) envoyée (reachable=\(session.isReachable, privacy: .public))") + } +} + +// MARK: - Intents des boutons de la Live Activity + +/// Bouton Pause / Reprendre. `LiveActivityIntent` est indispensable : sans +/// cette conformité, `perform()` n'est jamais appelé depuis une Live Activity. +@available(iOS 17.0, *) +struct CoachTogglePauseIntent: LiveActivityIntent { + static var title: LocalizedStringResource = "Mettre la séance en pause" + /// Hors Raccourcis / Spotlight : ce n'est pas une action autonome. + static var isDiscoverable: Bool = false + + @Parameter(title: "Reprendre") + var resuming: Bool + + init() {} + init(resuming: Bool) { self.resuming = resuming } + + func perform() async throws -> some IntentResult { + CoachWatchCommand.send(resuming ? CoachWatchCommand.resume : CoachWatchCommand.pause) + return .result() + } +} + +/// Bouton Terminer. +@available(iOS 17.0, *) +struct CoachEndWorkoutIntent: LiveActivityIntent { + static var title: LocalizedStringResource = "Terminer la séance" + static var isDiscoverable: Bool = false + + init() {} + + func perform() async throws -> some IntentResult { + CoachWatchCommand.send(CoachWatchCommand.end) + return .result() + } +} diff --git a/ios/App/CoachLiveActivity/CoachLiveActivityWidget.swift b/ios/App/CoachLiveActivity/CoachLiveActivityWidget.swift index 8b2efb9..06d99dd 100644 --- a/ios/App/CoachLiveActivity/CoachLiveActivityWidget.swift +++ b/ios/App/CoachLiveActivity/CoachLiveActivityWidget.swift @@ -41,18 +41,21 @@ struct CoachLiveActivityWidget: Widget { .foregroundStyle(.secondary) } DynamicIslandExpandedRegion(.bottom) { - HStack(spacing: 18) { - metricInline(label: "Distance", - value: distKmText(context.state), - unit: "km", - color: .orange) - Divider().frame(height: 28) - metricInline(label: "Durée", - value: elapsedText(context.state.elapsedSec), - unit: "", - color: .white) + VStack(spacing: 10) { + HStack(spacing: 18) { + metricInline(label: "Distance", + value: distKmText(context.state), + unit: "km", + color: .orange) + Divider().frame(height: 28) + metricInline(label: "Durée", + value: elapsedText(context.state.elapsedSec), + unit: "", + color: .white) + } + .frame(maxWidth: .infinity) + controls(state: context.state) } - .frame(maxWidth: .infinity) } } compactLeading: { HStack(spacing: 3) { @@ -101,11 +104,41 @@ struct CoachLiveActivityWidget: Widget { metricBlock(value: distKmText(context.state), unit: "km", color: .orange) metricBlock(value: elapsedText(context.state.elapsedSec), unit: "", color: .white) } + controls(state: context.state) } .padding(.horizontal, 14) .padding(.vertical, 12) } + // MARK: - Contrôles (iOS 17+) + + /// Pause/Reprendre et Terminer, comme l'app Exercice. + /// Sur iOS 16, ActivityKit n'accepte aucun bouton : la Live Activity reste + /// purement informative, sans rien casser. + @ViewBuilder + private func controls(state: CoachLiveActivityAttributes.ContentState) -> some View { + if #available(iOS 17.0, *) { + HStack(spacing: 10) { + Button(intent: CoachTogglePauseIntent(resuming: state.isPaused)) { + Label(state.isPaused ? "Reprendre" : "Pause", + systemImage: state.isPaused ? "play.fill" : "pause.fill") + .font(.caption.weight(.semibold)) + .frame(maxWidth: .infinity) + } + .tint(state.isPaused ? .green : .yellow) + + Button(intent: CoachEndWorkoutIntent()) { + Label("Terminer", systemImage: "stop.fill") + .font(.caption.weight(.semibold)) + .frame(maxWidth: .infinity) + } + .tint(.red) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + @ViewBuilder private func metricBlock(value: String, unit: String, color: Color) -> some View { VStack(spacing: 0) { diff --git a/ios/App/CoachWatch/ConnectivityManager.swift b/ios/App/CoachWatch/ConnectivityManager.swift index 588da58..ac9638b 100644 --- a/ios/App/CoachWatch/ConnectivityManager.swift +++ b/ios/App/CoachWatch/ConnectivityManager.swift @@ -102,6 +102,39 @@ final class ConnectivityManager: NSObject, ObservableObject { return true } + /// Signale à l'iPhone que la séance est en pause / a repris, pour que le + /// bouton de la Live Activity affiche le bon état. + func sendPaused(_ paused: Bool) { + queue.async { + let session = WCSession.default + guard session.activationState == .activated else { return } + let payload: [String: Any] = ["paused": paused, "ts": Date().timeIntervalSince1970] + if session.isReachable { + session.sendMessage(payload, replyHandler: nil) { _ in + session.transferUserInfo(payload) + } + } else { + session.transferUserInfo(payload) + } + } + } + + /// Commande de pilotage envoyée par la Live Activity de l'iPhone. + /// Arrive soit par `sendMessage` (immédiat), soit par `transferUserInfo` + /// (différé) quand la montre n'était pas joignable. + private func handleCommand(_ dict: [String: Any]) { + guard let cmd = dict["workoutCommand"] as? String else { return } + connLog.info("commande reçue de l'iPhone : \(cmd, privacy: .public)") + Task { @MainActor in + switch cmd { + case "pause": WorkoutManager.shared.pause() + case "resume": WorkoutManager.shared.resume() + case "end": WorkoutManager.shared.end() + default: connLog.error("commande inconnue : \(cmd, privacy: .public)") + } + } + } + /// À appeler sur `queue` uniquement. private func flushLocked() { guard !buffer.isEmpty else { return } @@ -146,6 +179,12 @@ extension ConnectivityManager: WCSessionDelegate { func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { storeWidgetSnapshot(userInfo) storeRoutineSnapshot(userInfo) + handleCommand(userInfo) + } + + /// Temps réel : commandes de la Live Activity quand la montre est joignable. + func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + handleCommand(message) } /// Routine quotidienne poussée par l'iPhone (`CoachRoutineBridge`). diff --git a/ios/App/CoachWatch/ContentView.swift b/ios/App/CoachWatch/ContentView.swift index 68e53b9..887ada7 100644 --- a/ios/App/CoachWatch/ContentView.swift +++ b/ios/App/CoachWatch/ContentView.swift @@ -109,6 +109,18 @@ struct LiveWorkoutView: View { .foregroundStyle(.secondary) } + // Pause/reprise : la Live Activity de l'iPhone pilote les mêmes + // appels, l'état affiché ici doit donc suivre `isPaused` et non + // un état local au bouton. + Button { + workout.isPaused ? workout.resume() : workout.pause() + } label: { + Label(workout.isPaused ? "Reprendre" : "Pause", + systemImage: workout.isPaused ? "play.fill" : "pause.fill") + .frame(maxWidth: .infinity) + } + .tint(workout.isPaused ? .green : .yellow) + Button(role: .destructive) { workout.end() } label: { diff --git a/ios/App/CoachWatch/WorkoutManager.swift b/ios/App/CoachWatch/WorkoutManager.swift index a9a89e9..6eb241f 100644 --- a/ios/App/CoachWatch/WorkoutManager.swift +++ b/ios/App/CoachWatch/WorkoutManager.swift @@ -16,6 +16,7 @@ final class WorkoutManager: NSObject, ObservableObject { @Published private(set) var distanceMeters = 0.0 @Published private(set) var elapsedSec = 0.0 @Published private(set) var isRunning = false + @Published private(set) var isPaused = false @Published private(set) var statusMessage: String? private let healthStore = HKHealthStore() @@ -88,8 +89,27 @@ final class WorkoutManager: NSObject, ObservableObject { } } - func pause() { session?.pause() } - func resume() { session?.resume() } + /// Pause / reprise. Déclenchées depuis la montre OU depuis les boutons de la + /// Live Activity de l'iPhone (via `ConnectivityManager.handleCommand`). + /// Avec une vraie session, `isPaused` n'est pas positionné ici : il suit le + /// changement d'état HealthKit, seule source fiable (la montre peut aussi + /// mettre en pause d'elle-même). + func pause() { + guard simTask == nil else { setSimPaused(true); return } + session?.pause() + } + + func resume() { + guard simTask == nil else { setSimPaused(false); return } + session?.resume() + } + + /// Le mode test ne passe pas par HKWorkoutSession : il n'émet aucun + /// changement d'état, l'iPhone doit donc être prévenu ici. + private func setSimPaused(_ paused: Bool) { + isPaused = paused + ConnectivityManager.shared.sendPaused(paused) + } func end() { if let simTask { @@ -203,6 +223,16 @@ extension WorkoutManager: HKWorkoutSessionDelegate { from fromState: HKWorkoutSessionState, date: Date) { workoutLog.info("session didChangeTo state=\(toState.rawValue) from=\(fromState.rawValue)") + // Pause / reprise : c'est la session HealthKit qui fait foi, pas + // l'appel à pause() — la montre peut mettre en pause d'elle-même + // (auto-pause) sans passer par nous. + if toState == .paused || toState == .running { + let paused = (toState == .paused) + Task { @MainActor in + self.isPaused = paused + ConnectivityManager.shared.sendPaused(paused) + } + } guard toState == .ended else { return } let builder = workoutSession.associatedWorkoutBuilder() Task { @@ -211,6 +241,7 @@ extension WorkoutManager: HKWorkoutSessionDelegate { } Task { @MainActor in self.isRunning = false + self.isPaused = false self.session = nil self.builder = nil // Fin explicite vers l'iPhone : sans elle, sa Live Activity reste