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>
78 lines
2.7 KiB
Swift
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")
|
|
}
|
|
}
|