feat(live): Live Activity / Dynamic Island (WidgetKit extension iOS 16.2+/17+)

Nouvelle target "CoachLiveActivity" (Widget Extension, bundle
ch.hypnotruck.coach.LiveActivity, iOS 17.0) embarquée dans App via copy phase
"Embed App Extensions" (dstSubfolderSpec=13). API ActivityKit + WidgetKit
vérifiées sur SDK iOS 26.5 swiftinterface (Activity.request(content:),
ActivityConfiguration, DynamicIsland(expanded:compactLeading:trailing:minimal:)).

Architecture :
- CoachLiveActivityAttributes (modèle partagé App ↔ extension)
- CoachLiveActivityWidget (Lock Screen + Dynamic Island UI)
- CoachLiveActivityBundle (@main WidgetBundle)
- LiveActivityManager (App side, wrap Activity.request/update/end)
- LiveStore hook : start sur 1er sample, update à chaque ingest, end après
  60s "lost". Tout guardé @available(iOS 16.2, *) pour préserver deployment iOS 15.
- Info.plist App : NSSupportsLiveActivities = YES.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain
2026-05-26 19:51:57 +02:00
parent f65d96e908
commit a5795bf83f
8 changed files with 533 additions and 2 deletions

View File

@@ -0,0 +1,23 @@
// CoachLiveActivityAttributes.swift
// Modèle partagé App Widget Extension (membre des 2 targets).
// Définit ce que la Live Activity / Dynamic Island affiche.
import ActivityKit
import Foundation
@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
}
/// 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
}

View File

@@ -0,0 +1,13 @@
// CoachLiveActivityBundle.swift
// Entry point @main de l'extension WidgetKit. Contient le ou les Widgets
// exposés par cette extension. Ici juste la Live Activity du live workout.
import WidgetKit
import SwiftUI
@main
struct CoachLiveActivityBundle: WidgetBundle {
var body: some Widget {
CoachLiveActivityWidget()
}
}

View File

@@ -0,0 +1,158 @@
// CoachLiveActivityWidget.swift
// Définition Widget pour la Live Activity (Lock Screen + Dynamic Island).
// Structure validée sur le template Apple Xcode 26.5 (Widget Extension /
// LiveActivity.swift) + SDK swiftinterface WidgetKit iOS 26.5
// (DynamicIsland init signature : expanded:compactLeading:compactTrailing:minimal:).
import ActivityKit
import WidgetKit
import SwiftUI
struct CoachLiveActivityWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: CoachLiveActivityAttributes.self) { context in
// Lock screen / banner
lockScreenView(context: context)
.activityBackgroundTint(Color.black.opacity(0.55))
.activitySystemActionForegroundColor(.white)
} dynamicIsland: { context in
DynamicIsland {
// Expanded UI (long-press l'île)
DynamicIslandExpandedRegion(.leading) {
metricExpanded(label: "FC",
value: hrText(context.state),
color: .red,
symbol: "heart.fill")
}
DynamicIslandExpandedRegion(.trailing) {
metricExpanded(label: "Kcal",
value: kcalText(context.state),
color: .green,
symbol: "flame.fill")
}
DynamicIslandExpandedRegion(.center) {
Label(context.attributes.activityLabel,
systemImage: context.attributes.activitySymbol)
.font(.caption2.weight(.semibold))
.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)
}
.frame(maxWidth: .infinity)
}
} compactLeading: {
HStack(spacing: 3) {
Image(systemName: "heart.fill")
.foregroundStyle(.red)
Text(hrText(context.state))
.font(.caption2.weight(.bold).monospacedDigit())
}
} compactTrailing: {
Text(elapsedText(context.state.elapsedSec))
.font(.caption2.weight(.semibold).monospacedDigit())
.foregroundStyle(.white)
} minimal: {
Image(systemName: "heart.fill")
.foregroundStyle(.red)
.symbolEffect(.pulse, isActive: context.state.isLive)
}
.keylineTint(.red)
}
}
// MARK: - Lock screen layout
@ViewBuilder
private func lockScreenView(context: ActivityViewContext<CoachLiveActivityAttributes>) -> some View {
VStack(spacing: 12) {
HStack(spacing: 8) {
Image(systemName: context.attributes.activitySymbol)
.foregroundStyle(.white)
Text(context.attributes.activityLabel)
.font(.headline)
Spacer()
Image(systemName: "heart.fill")
.foregroundStyle(.red)
.symbolEffect(.pulse, isActive: context.state.isLive)
Text(context.state.isLive ? "En direct" : "Connexion perdue")
.font(.caption.weight(.semibold))
.foregroundStyle(context.state.isLive ? .green : .orange)
}
HStack(spacing: 14) {
metricBlock(value: hrText(context.state), unit: "bpm", color: .red)
metricBlock(value: kcalText(context.state), unit: "kcal", color: .green)
metricBlock(value: distKmText(context.state), unit: "km", color: .orange)
metricBlock(value: elapsedText(context.state.elapsedSec), unit: "", color: .white)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
}
@ViewBuilder
private func metricBlock(value: String, unit: String, color: Color) -> some View {
VStack(spacing: 0) {
Text(value)
.font(.system(.title3, design: .rounded).weight(.heavy).monospacedDigit())
.foregroundStyle(color)
if !unit.isEmpty {
Text(unit).font(.caption2).foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity)
}
@ViewBuilder
private func metricExpanded(label: String, value: String, color: Color, symbol: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Label(label, systemImage: symbol)
.font(.caption2.weight(.semibold))
.foregroundStyle(color)
Text(value)
.font(.title2.weight(.heavy).monospacedDigit())
.foregroundStyle(color)
}
}
@ViewBuilder
private func metricInline(label: String, value: String, unit: String, color: Color) -> some View {
HStack(spacing: 4) {
Text(label).font(.caption2).foregroundStyle(.secondary)
Text(value).font(.callout.weight(.bold).monospacedDigit()).foregroundStyle(color)
if !unit.isEmpty {
Text(unit).font(.caption2).foregroundStyle(.tertiary)
}
}
}
// MARK: - Formatters (dupliqués ici car non-import de LiveStore depuis l'extension)
private func hrText(_ s: CoachLiveActivityAttributes.ContentState) -> String {
s.heartRate > 0 ? "\(Int(s.heartRate.rounded()))" : ""
}
private func kcalText(_ s: CoachLiveActivityAttributes.ContentState) -> String {
s.activeEnergyKcal > 0 ? "\(Int(s.activeEnergyKcal.rounded()))" : ""
}
private func distKmText(_ s: CoachLiveActivityAttributes.ContentState) -> String {
s.distanceMeters > 0 ? String(format: "%.2f", s.distanceMeters / 1000) : ""
}
private func elapsedText(_ sec: TimeInterval) -> String {
let total = Int(sec)
let h = total / 3600
let m = (total % 3600) / 60
let s = total % 60
if h > 0 { return "\(h):\(String(format: "%02d", m)):\(String(format: "%02d", s))" }
return "\(m):\(String(format: "%02d", s))"
}
}

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>Coach Live</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>