Piloter la séance depuis la Live Activity (Pause / Terminer)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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`).
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user