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:
158
ios/App/CoachLiveActivity/CoachLiveActivityWidget.swift
Normal file
158
ios/App/CoachLiveActivity/CoachLiveActivityWidget.swift
Normal 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))"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user