Files
coach-ios/ios/App/CoachLiveActivity/CoachLiveActivityAttributes.swift
Sylvain Bettinelli 4f8699fd37 Vitesse en km/h sur la montre et dans la Live Activity
Suite de la demande : voir des km/h en courant, ce que l'app Exercice
d'Apple ne permet pas (vitesse réservée au vélo, allure min/km imposée en
course, aucun réglage).

La vitesse instantanée vient de HealthKit lui-même : HKLiveWorkoutDataSource
collecte .runningSpeed d'office en course extérieure (Series 6+) et
.cyclingSpeed à vélo (watchOS 11+). Elle est lue en mostRecentQuantity —
c'est la vitesse à l'instant t qu'on veut afficher, pas la moyenne de la
séance. Sur un appareil qui ne la produit pas, repli sur distance/temps.

Affichée sur les trois surfaces : écran de séance de la montre, Live
Activity (bannière + île dépliée) et vue native /live.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 13:31:13 +00:00

122 lines
4.9 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
/// Vitesse instantanée en km/h (mesurée par la montre, sinon moyenne
/// dérivée). Apple n'affiche jamais de km/h en course : c'est le
/// principal apport de cette Live Activity face à l'app Exercice.
var speedKmh: Double = 0
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()
}
}