Files
Ascently/ios/Ascently/ViewModels/LiveActivityManager.swift
2025-11-18 12:58:45 -07:00

285 lines
11 KiB
Swift

import ActivityKit
import Foundation
extension Notification.Name {
static let liveActivityDismissed = Notification.Name("liveActivityDismissed")
}
@MainActor
final class LiveActivityManager {
static let shared = LiveActivityManager()
private static let logTag = "LiveActivity"
private init() {}
nonisolated(unsafe) private var currentActivity: Activity<SessionActivityAttributes>?
private var healthCheckTimer: Timer?
private var lastHealthCheck: Date = Date()
/// Check if there's an active session and restart Live Activity if needed
func restartLiveActivityIfNeeded(activeSession: ClimbSession?, gymName: String?) async {
// If we have an active session but no Live Activity, restart it
guard let activeSession = activeSession,
let gymName = gymName,
activeSession.status == .active
else {
return
}
// Check if we have a tracked Live Activity that's still actually running
if let currentActivity = currentActivity {
let activities = Activity<SessionActivityAttributes>.activities
let isStillActive = activities.contains { $0.id == currentActivity.id }
if isStillActive {
AppLogger.debug("Live Activity still running: \(currentActivity.id)", tag: Self.logTag)
return
} else {
AppLogger.warning(
"Tracked Live Activity \(currentActivity.id) was dismissed, clearing reference",
tag: Self.logTag
)
self.currentActivity = nil
}
}
// Check if there are ANY active Live Activities for this session
let existingActivities = Activity<SessionActivityAttributes>.activities
if let existingActivity = existingActivities.first {
AppLogger.info("Found existing Live Activity: \(existingActivity.id), using it", tag: Self.logTag)
self.currentActivity = existingActivity
return
}
AppLogger.info("No Live Activity found, restarting for existing session", tag: Self.logTag)
await startLiveActivity(for: activeSession, gymName: gymName)
}
/// Call this when a ClimbSession starts to begin a Live Activity
func startLiveActivity(for session: ClimbSession, gymName: String) async {
AppLogger.info("Starting Live Activity for gym: \(gymName)", tag: Self.logTag)
await endLiveActivity()
// Start health checks once we have an active session
startHealthChecks()
// Calculate elapsed time if session already started
let startTime = session.startTime ?? session.date
let elapsed = Date().timeIntervalSince(startTime)
let attributes = SessionActivityAttributes(
gymName: gymName, startTime: startTime)
let initialContentState = SessionActivityAttributes.ContentState(
elapsed: elapsed,
totalAttempts: 0,
completedProblems: 0
)
do {
let activity = try Activity<SessionActivityAttributes>.request(
attributes: attributes,
content: .init(state: initialContentState, staleDate: nil),
pushType: nil
)
self.currentActivity = activity
AppLogger.info("Live Activity started successfully: \(activity.id)", tag: Self.logTag)
} catch {
AppLogger.error(
"""
Failed to start live activity: \(error)
Details: \(error.localizedDescription)
""",
tag: Self.logTag
)
// Check specific error types
if error.localizedDescription.contains("authorization") {
AppLogger.warning(
"Authorization error - check Live Activity permissions in Settings",
tag: Self.logTag
)
} else if error.localizedDescription.contains("content") {
AppLogger.warning("Content error - check ActivityAttributes structure", tag: Self.logTag)
} else if error.localizedDescription.contains("frequencyLimited") {
AppLogger.warning("Frequency limited - too many Live Activities started recently", tag: Self.logTag)
}
}
}
/// Call this to update the Live Activity with new session progress
func updateLiveActivity(elapsed: TimeInterval, totalAttempts: Int, completedProblems: Int) async
{
guard let currentActivity = currentActivity else {
AppLogger.warning("No current activity to update", tag: Self.logTag)
return
}
// Verify the activity is still valid before updating
let activities = Activity<SessionActivityAttributes>.activities
let isStillActive = activities.contains { $0.id == currentActivity.id }
if !isStillActive {
AppLogger.warning(
"Tracked Live Activity \(currentActivity.id) is no longer active, clearing reference",
tag: Self.logTag
)
self.currentActivity = nil
return
}
AppLogger.debug(
"Updating Live Activity - Attempts: \(totalAttempts), Completed: \(completedProblems)",
tag: Self.logTag
)
let updatedContentState = SessionActivityAttributes.ContentState(
elapsed: elapsed,
totalAttempts: totalAttempts,
completedProblems: completedProblems
)
nonisolated(unsafe) let activity = currentActivity
await activity.update(.init(state: updatedContentState, staleDate: nil))
}
/// Call this when a ClimbSession ends to end the Live Activity
func endLiveActivity() async {
// Stop health checks first
stopHealthChecks()
// First end the tracked activity if it exists
if let currentActivity {
AppLogger.info("Ending tracked Live Activity: \(currentActivity.id)", tag: Self.logTag)
nonisolated(unsafe) let activity = currentActivity
await activity.end(nil, dismissalPolicy: .immediate)
self.currentActivity = nil
AppLogger.info("Tracked Live Activity ended successfully", tag: Self.logTag)
}
// Force end ALL active activities of our type to ensure cleanup
AppLogger.debug("Checking for any remaining active activities...", tag: Self.logTag)
let activities = Activity<SessionActivityAttributes>.activities
if activities.isEmpty {
AppLogger.debug("No additional activities found", tag: Self.logTag)
} else {
AppLogger.info("Found \(activities.count) additional active activities, ending them...", tag: Self.logTag)
for activity in activities {
AppLogger.debug("Force ending activity: \(activity.id)", tag: Self.logTag)
await activity.end(nil, dismissalPolicy: .immediate)
}
AppLogger.info("All Live Activities ended successfully", tag: Self.logTag)
}
}
/// Check if Live Activities are available and authorized
func checkLiveActivityAvailability() -> String {
let authorizationInfo = ActivityAuthorizationInfo()
let status = authorizationInfo.areActivitiesEnabled
let allActivities = Activity<SessionActivityAttributes>.activities
let message = """
Live Activity Status:
• Enabled: \(status)
• Authorization: \(authorizationInfo.areActivitiesEnabled ? "Granted" : "Denied/Unknown")
• Tracked Activity: \(currentActivity?.id.description ?? "None")
• All Active Activities: \(allActivities.count)
"""
AppLogger.info(message, tag: Self.logTag)
return message
}
/// Force check and cleanup dismissed Live Activities
func cleanupDismissedActivities() async {
let activities = Activity<SessionActivityAttributes>.activities
if let currentActivity = currentActivity {
let isStillActive = activities.contains { $0.id == currentActivity.id }
if !isStillActive {
AppLogger.info("Cleaning up dismissed Live Activity: \(currentActivity.id)", tag: Self.logTag)
self.currentActivity = nil
}
}
}
/// Start periodic health checks for Live Activity
func startHealthChecks() {
stopHealthChecks() // Stop any existing timer
AppLogger.debug("🩺 Starting Live Activity health checks", tag: Self.logTag)
healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) {
[weak self] _ in
Task { @MainActor [weak self] in
await self?.performHealthCheck()
}
}
}
/// Stop periodic health checks
func stopHealthChecks() {
healthCheckTimer?.invalidate()
healthCheckTimer = nil
AppLogger.debug("Stopped Live Activity health checks", tag: Self.logTag)
}
/// Perform a health check on the current Live Activity
private func performHealthCheck() async {
guard let currentActivity = currentActivity else { return }
let now = Date()
let timeSinceLastCheck = now.timeIntervalSince(lastHealthCheck)
// Only perform health check if it's been at least 25 seconds
guard timeSinceLastCheck >= 25 else { return }
AppLogger.debug("🩺 Performing Live Activity health check", tag: Self.logTag)
lastHealthCheck = now
let activities = Activity<SessionActivityAttributes>.activities
let isStillActive = activities.contains { $0.id == currentActivity.id }
if !isStillActive {
AppLogger.warning("Health check failed - Live Activity was dismissed", tag: Self.logTag)
self.currentActivity = nil
// Notify that we need to restart
NotificationCenter.default.post(
name: .liveActivityDismissed,
object: nil
)
} else {
AppLogger.debug("Live Activity health check passed", tag: Self.logTag)
}
}
/// Get the current activity status for debugging
func getCurrentActivityStatus() -> String {
let activities = Activity<SessionActivityAttributes>.activities
let trackedStatus = currentActivity != nil ? "Tracked" : "None"
let actualCount = activities.count
return "Status: \(trackedStatus) | Active Count: \(actualCount)"
}
/// Start periodic updates for Live Activity
func startPeriodicUpdates(for session: ClimbSession, totalAttempts: Int, completedProblems: Int)
{
guard currentActivity != nil else { return }
Task { @MainActor in
while currentActivity != nil {
let elapsed = Date().timeIntervalSince(session.startTime ?? session.date)
await updateLiveActivity(
elapsed: elapsed,
totalAttempts: totalAttempts,
completedProblems: completedProblems
)
// Wait 30 seconds before next update
try? await Task.sleep(nanoseconds: 30_000_000_000)
}
}
}
}