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:
Sylvain Bettinelli
2026-08-11 13:25:14 +00:00
parent ce048ee634
commit 21d39f2e98
8 changed files with 283 additions and 15 deletions

View File

@@ -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()
}
}

View File

@@ -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) {