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>
118 lines
4.7 KiB
Swift
118 lines
4.7 KiB
Swift
// CoachLiveActivityAttributes.swift
|
||
// Modèle partagé App ↔ Widget Extension (membre des 2 targets).
|
||
// 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 {
|
||
/// Données dynamiques (mises à jour à chaque sample reçu de la Watch).
|
||
public struct ContentState: Codable, Hashable {
|
||
var heartRate: Double
|
||
var activeEnergyKcal: Double
|
||
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).
|
||
var activityLabel: String // "Course", "Vélo", "Marche", "Renfo", ou "Séance"
|
||
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()
|
||
}
|
||
}
|