Files
coach-ios/ios/App/App/LiveActivityManager.swift
Sylvain a5795bf83f 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>
2026-05-26 19:51:57 +02:00

78 lines
2.7 KiB
Swift

// LiveActivityManager.swift
// Wrapper main-actor autour d'ActivityKit pour la Live Activity / Dynamic
// Island du live workout. Appelé par LiveStore à chaque transition / sample.
// API ActivityKit vérifiée sur iOS 26.5 SDK swiftinterface :
// Activity.request(attributes:, content: ActivityContent<...>, pushType:)
// activity.update(_ content: ActivityContent<...>) async
// activity.end(_ content: ActivityContent<...>?, dismissalPolicy:) async
// ActivityAuthorizationInfo().areActivitiesEnabled
import ActivityKit
import Foundation
import os
@available(iOS 16.2, *)
@MainActor
final class LiveActivityManager {
static let shared = LiveActivityManager()
private var current: Activity<CoachLiveActivityAttributes>?
private static let log = Logger(subsystem: "ch.hypnotruck.coach", category: "live-activity")
private init() {}
private var isAuthorized: Bool {
ActivityAuthorizationInfo().areActivitiesEnabled
}
/// Démarre l'activité si pas déjà en cours. Si déjà en cours, met à jour.
func start(label: String,
symbol: String,
state: CoachLiveActivityAttributes.ContentState) {
if current != nil {
update(state: state)
return
}
guard isAuthorized else {
Self.log.warning("Live Activities not authorized (user disabled in Settings)")
return
}
let attrs = CoachLiveActivityAttributes(
activityLabel: label,
activitySymbol: symbol,
startedAt: Date()
)
let content = ActivityContent(
state: state,
staleDate: Date().addingTimeInterval(60)
)
do {
current = try Activity.request(
attributes: attrs,
content: content,
pushType: nil
)
Self.log.info("Live Activity started: \(label, privacy: .public)")
} catch {
Self.log.error("Failed to start Live Activity: \(error.localizedDescription, privacy: .public)")
}
}
/// Met à jour l'activité en cours (no-op si pas démarrée).
func update(state: CoachLiveActivityAttributes.ContentState) {
guard let activity = current else { return }
let content = ActivityContent(
state: state,
staleDate: Date().addingTimeInterval(60)
)
Task { await activity.update(content) }
}
/// Termine l'activité. dismissalPolicy .default = bannière reste ~4h.
func end() {
guard let activity = current else { return }
current = nil
Task { await activity.end(nil, dismissalPolicy: .default) }
Self.log.info("Live Activity ended")
}
}