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:
@@ -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,
|
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).
|
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)*
|
## Étape 5 — TestFlight *(facultatif)*
|
||||||
|
|
||||||
Rien de spécifique ici : c'est la procédure habituelle, étapes 7 à 9 de
|
Rien de spécifique ici : c'est la procédure habituelle, étapes 7 à 9 de
|
||||||
|
|||||||
@@ -135,6 +135,15 @@ extension CoachLiveBridgePlugin: WCSessionDelegate {
|
|||||||
)
|
)
|
||||||
return true
|
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
|
// 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
|
// est indistinguable d'une perte de portée : la Live Activity restait
|
||||||
// figée sur la dernière FC.
|
// figée sur la dernière FC.
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ final class LiveStore: ObservableObject {
|
|||||||
@Published var activityType: String? = nil
|
@Published var activityType: String? = nil
|
||||||
@Published var status: Status = .waiting
|
@Published var status: Status = .waiting
|
||||||
@Published var lastSampleAt: Date? = nil
|
@Published var lastSampleAt: Date? = nil
|
||||||
|
@Published var isPaused: Bool = false
|
||||||
|
|
||||||
private var watchdog: Timer?
|
private var watchdog: Timer?
|
||||||
private let lostThreshold: TimeInterval = 8
|
private let lostThreshold: TimeInterval = 8
|
||||||
@@ -35,6 +36,10 @@ final class LiveStore: ObservableObject {
|
|||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
guard let ts = self.lastSampleAt 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)
|
let sinceLast = Date().timeIntervalSince(ts)
|
||||||
if self.status == .live, sinceLast > self.lostThreshold {
|
if self.status == .live, sinceLast > self.lostThreshold {
|
||||||
// Transition live -> lost : signale à la Live Activity
|
// 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
|
/// 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
|
/// 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
|
/// 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.status = .waiting
|
||||||
self.lostSince = nil
|
self.lostSince = nil
|
||||||
self.lastSampleAt = nil
|
self.lastSampleAt = nil
|
||||||
|
self.isPaused = false
|
||||||
if #available(iOS 16.2, *) {
|
if #available(iOS 16.2, *) {
|
||||||
LiveActivityManager.shared.end()
|
LiveActivityManager.shared.end()
|
||||||
}
|
}
|
||||||
@@ -119,7 +138,8 @@ final class LiveStore: ObservableObject {
|
|||||||
activeEnergyKcal: activeEnergyKcal,
|
activeEnergyKcal: activeEnergyKcal,
|
||||||
distanceMeters: distanceMeters,
|
distanceMeters: distanceMeters,
|
||||||
elapsedSec: elapsedSec,
|
elapsedSec: elapsedSec,
|
||||||
isLive: isLive
|
isLive: isLive,
|
||||||
|
isPaused: isPaused
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
// CoachLiveActivityAttributes.swift
|
// CoachLiveActivityAttributes.swift
|
||||||
// Modèle partagé App ↔ Widget Extension (membre des 2 targets).
|
// 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 ActivityKit
|
||||||
|
import AppIntents
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import WatchConnectivity
|
||||||
|
import os
|
||||||
|
|
||||||
@available(iOS 16.2, *)
|
@available(iOS 16.2, *)
|
||||||
struct CoachLiveActivityAttributes: ActivityAttributes {
|
struct CoachLiveActivityAttributes: ActivityAttributes {
|
||||||
@@ -14,6 +25,10 @@ struct CoachLiveActivityAttributes: ActivityAttributes {
|
|||||||
var distanceMeters: Double
|
var distanceMeters: Double
|
||||||
var elapsedSec: TimeInterval
|
var elapsedSec: TimeInterval
|
||||||
var isLive: Bool // true = direct ; false = perdu/en pause
|
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).
|
/// 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 activitySymbol: String // SF Symbol (figure.run, bicycle, figure.walk, dumbbell)
|
||||||
var startedAt: Date
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ struct CoachLiveActivityWidget: Widget {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
DynamicIslandExpandedRegion(.bottom) {
|
DynamicIslandExpandedRegion(.bottom) {
|
||||||
|
VStack(spacing: 10) {
|
||||||
HStack(spacing: 18) {
|
HStack(spacing: 18) {
|
||||||
metricInline(label: "Distance",
|
metricInline(label: "Distance",
|
||||||
value: distKmText(context.state),
|
value: distKmText(context.state),
|
||||||
@@ -53,6 +54,8 @@ struct CoachLiveActivityWidget: Widget {
|
|||||||
color: .white)
|
color: .white)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
controls(state: context.state)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} compactLeading: {
|
} compactLeading: {
|
||||||
HStack(spacing: 3) {
|
HStack(spacing: 3) {
|
||||||
@@ -101,11 +104,41 @@ struct CoachLiveActivityWidget: Widget {
|
|||||||
metricBlock(value: distKmText(context.state), unit: "km", color: .orange)
|
metricBlock(value: distKmText(context.state), unit: "km", color: .orange)
|
||||||
metricBlock(value: elapsedText(context.state.elapsedSec), unit: "", color: .white)
|
metricBlock(value: elapsedText(context.state.elapsedSec), unit: "", color: .white)
|
||||||
}
|
}
|
||||||
|
controls(state: context.state)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 12)
|
.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
|
@ViewBuilder
|
||||||
private func metricBlock(value: String, unit: String, color: Color) -> some View {
|
private func metricBlock(value: String, unit: String, color: Color) -> some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
|||||||
@@ -102,6 +102,39 @@ final class ConnectivityManager: NSObject, ObservableObject {
|
|||||||
return true
|
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.
|
/// À appeler sur `queue` uniquement.
|
||||||
private func flushLocked() {
|
private func flushLocked() {
|
||||||
guard !buffer.isEmpty else { return }
|
guard !buffer.isEmpty else { return }
|
||||||
@@ -146,6 +179,12 @@ extension ConnectivityManager: WCSessionDelegate {
|
|||||||
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
||||||
storeWidgetSnapshot(userInfo)
|
storeWidgetSnapshot(userInfo)
|
||||||
storeRoutineSnapshot(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`).
|
/// Routine quotidienne poussée par l'iPhone (`CoachRoutineBridge`).
|
||||||
|
|||||||
@@ -109,6 +109,18 @@ struct LiveWorkoutView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.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) {
|
Button(role: .destructive) {
|
||||||
workout.end()
|
workout.end()
|
||||||
} label: {
|
} label: {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ final class WorkoutManager: NSObject, ObservableObject {
|
|||||||
@Published private(set) var distanceMeters = 0.0
|
@Published private(set) var distanceMeters = 0.0
|
||||||
@Published private(set) var elapsedSec = 0.0
|
@Published private(set) var elapsedSec = 0.0
|
||||||
@Published private(set) var isRunning = false
|
@Published private(set) var isRunning = false
|
||||||
|
@Published private(set) var isPaused = false
|
||||||
@Published private(set) var statusMessage: String?
|
@Published private(set) var statusMessage: String?
|
||||||
|
|
||||||
private let healthStore = HKHealthStore()
|
private let healthStore = HKHealthStore()
|
||||||
@@ -88,8 +89,27 @@ final class WorkoutManager: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func pause() { session?.pause() }
|
/// Pause / reprise. Déclenchées depuis la montre OU depuis les boutons de la
|
||||||
func resume() { session?.resume() }
|
/// 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() {
|
func end() {
|
||||||
if let simTask {
|
if let simTask {
|
||||||
@@ -203,6 +223,16 @@ extension WorkoutManager: HKWorkoutSessionDelegate {
|
|||||||
from fromState: HKWorkoutSessionState,
|
from fromState: HKWorkoutSessionState,
|
||||||
date: Date) {
|
date: Date) {
|
||||||
workoutLog.info("session didChangeTo state=\(toState.rawValue) from=\(fromState.rawValue)")
|
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 }
|
guard toState == .ended else { return }
|
||||||
let builder = workoutSession.associatedWorkoutBuilder()
|
let builder = workoutSession.associatedWorkoutBuilder()
|
||||||
Task {
|
Task {
|
||||||
@@ -211,6 +241,7 @@ extension WorkoutManager: HKWorkoutSessionDelegate {
|
|||||||
}
|
}
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.isRunning = false
|
self.isRunning = false
|
||||||
|
self.isPaused = false
|
||||||
self.session = nil
|
self.session = nil
|
||||||
self.builder = nil
|
self.builder = nil
|
||||||
// Fin explicite vers l'iPhone : sans elle, sa Live Activity reste
|
// Fin explicite vers l'iPhone : sans elle, sa Live Activity reste
|
||||||
|
|||||||
Reference in New Issue
Block a user