// 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) -> 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))" } }