Files
Ascently/ios/OpenClimb/ViewModels/ClimbingDataManager.swift

1079 lines
35 KiB
Swift

import Combine
import Foundation
import SwiftUI
import UniformTypeIdentifiers
#if canImport(WidgetKit)
import WidgetKit
#endif
#if canImport(ActivityKit)
import ActivityKit
#endif
@MainActor
class ClimbingDataManager: ObservableObject {
@Published var gyms: [Gym] = []
@Published var problems: [Problem] = []
@Published var sessions: [ClimbSession] = []
@Published var attempts: [Attempt] = []
@Published var activeSession: ClimbSession?
@Published var isLoading = false
@Published var errorMessage: String?
@Published var successMessage: String?
private let userDefaults = UserDefaults.standard
private let sharedUserDefaults = UserDefaults(suiteName: "group.com.atridad.OpenClimb")
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private var liveActivityObserver: NSObjectProtocol?
// Sync service for automatic syncing
let syncService = SyncService()
// Published property to propagate sync state changes
@Published var isSyncing = false
private enum Keys {
static let gyms = "openclimb_gyms"
static let problems = "openclimb_problems"
static let sessions = "openclimb_sessions"
static let attempts = "openclimb_attempts"
static let activeSession = "openclimb_active_session"
}
// Widget data models
private struct WidgetAttempt: Codable {
let id: String
let sessionId: String
let problemId: String
let timestamp: Date
let result: String
}
private struct WidgetSession: Codable {
let id: String
let gymId: String
let date: Date
let status: String
}
private struct WidgetGym: Codable {
let id: String
let name: String
}
init() {
_ = ImageManager.shared
loadAllData()
migrateImagePaths()
setupLiveActivityNotifications()
// Keep our published isSyncing in sync with syncService.isSyncing
syncService.$isSyncing
.assign(to: &$isSyncing)
Task {
try? await Task.sleep(nanoseconds: 2_000_000_000)
await performImageMaintenance()
// Check if we need to restart Live Activity for active session
await checkAndRestartLiveActivity()
}
}
deinit {
if let observer = liveActivityObserver {
NotificationCenter.default.removeObserver(observer)
}
}
private func loadAllData() {
loadGyms()
loadProblems()
loadSessions()
loadAttempts()
loadActiveSession()
}
private func loadGyms() {
if let data = userDefaults.data(forKey: Keys.gyms),
let loadedGyms = try? decoder.decode([Gym].self, from: data)
{
self.gyms = loadedGyms
}
}
private func loadProblems() {
if let data = userDefaults.data(forKey: Keys.problems),
let loadedProblems = try? decoder.decode([Problem].self, from: data)
{
self.problems = loadedProblems
}
}
private func loadSessions() {
if let data = userDefaults.data(forKey: Keys.sessions),
let loadedSessions = try? decoder.decode([ClimbSession].self, from: data)
{
self.sessions = loadedSessions
}
}
private func loadAttempts() {
if let data = userDefaults.data(forKey: Keys.attempts),
let loadedAttempts = try? decoder.decode([Attempt].self, from: data)
{
self.attempts = loadedAttempts
}
}
private func loadActiveSession() {
if let data = userDefaults.data(forKey: Keys.activeSession),
let loadedActiveSession = try? decoder.decode(ClimbSession.self, from: data)
{
self.activeSession = loadedActiveSession
}
}
private func saveGyms() {
if let data = try? encoder.encode(gyms) {
userDefaults.set(data, forKey: Keys.gyms)
// Share with widget - convert to widget format
let widgetGyms = gyms.map { gym in
WidgetGym(id: gym.id.uuidString, name: gym.name)
}
if let widgetData = try? encoder.encode(widgetGyms) {
sharedUserDefaults?.set(widgetData, forKey: Keys.gyms)
}
}
}
private func saveProblems() {
if let data = try? encoder.encode(problems) {
userDefaults.set(data, forKey: Keys.problems)
// Share with widget
sharedUserDefaults?.set(data, forKey: Keys.problems)
}
}
private func saveSessions() {
if let data = try? encoder.encode(sessions) {
userDefaults.set(data, forKey: Keys.sessions)
// Share with widget - convert to widget format
let widgetSessions = sessions.map { session in
WidgetSession(
id: session.id.uuidString,
gymId: session.gymId.uuidString,
date: session.date,
status: session.status.rawValue
)
}
if let widgetData = try? encoder.encode(widgetSessions) {
sharedUserDefaults?.set(widgetData, forKey: Keys.sessions)
}
}
}
private func saveAttempts() {
if let data = try? encoder.encode(attempts) {
userDefaults.set(data, forKey: Keys.attempts)
// Share with widget - convert to widget format
let widgetAttempts = attempts.map { attempt in
WidgetAttempt(
id: attempt.id.uuidString,
sessionId: attempt.sessionId.uuidString,
problemId: attempt.problemId.uuidString,
timestamp: attempt.timestamp,
result: attempt.result.rawValue
)
}
if let widgetData = try? encoder.encode(widgetAttempts) {
sharedUserDefaults?.set(widgetData, forKey: Keys.attempts)
}
// Update widget timeline
updateWidgetTimeline()
}
}
private func saveActiveSession() {
if let activeSession = activeSession,
let data = try? encoder.encode(activeSession)
{
userDefaults.set(data, forKey: Keys.activeSession)
} else {
userDefaults.removeObject(forKey: Keys.activeSession)
}
}
func addGym(_ gym: Gym) {
gyms.append(gym)
saveGyms()
DataStateManager.shared.updateDataState()
successMessage = "Gym added successfully"
clearMessageAfterDelay()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
func updateGym(_ gym: Gym) {
if let index = gyms.firstIndex(where: { $0.id == gym.id }) {
gyms[index] = gym
saveGyms()
DataStateManager.shared.updateDataState()
successMessage = "Gym updated successfully"
clearMessageAfterDelay()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
}
func deleteGym(_ gym: Gym) {
// Delete associated problems and their attempts first
let problemsToDelete = problems.filter { $0.gymId == gym.id }
for problem in problemsToDelete {
deleteProblem(problem)
}
// Delete associated sessions and their attempts
let sessionsToDelete = sessions.filter { $0.gymId == gym.id }
for session in sessionsToDelete {
deleteSession(session)
}
// Delete the gym
gyms.removeAll { $0.id == gym.id }
saveGyms()
DataStateManager.shared.updateDataState()
successMessage = "Gym deleted successfully"
clearMessageAfterDelay()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
func gym(withId id: UUID) -> Gym? {
return gyms.first { $0.id == id }
}
func addProblem(_ problem: Problem) {
problems.append(problem)
saveProblems()
DataStateManager.shared.updateDataState()
successMessage = "Problem added successfully"
clearMessageAfterDelay()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
func updateProblem(_ problem: Problem) {
if let index = problems.firstIndex(where: { $0.id == problem.id }) {
problems[index] = problem
saveProblems()
DataStateManager.shared.updateDataState()
successMessage = "Problem updated successfully"
clearMessageAfterDelay()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
}
func deleteProblem(_ problem: Problem) {
// Delete associated attempts first
attempts.removeAll { $0.problemId == problem.id }
saveAttempts()
// Delete associated images
ImageManager.shared.deleteImages(atPaths: problem.imagePaths)
// Delete the problem
problems.removeAll { $0.id == problem.id }
saveProblems()
DataStateManager.shared.updateDataState()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
func problem(withId id: UUID) -> Problem? {
return problems.first { $0.id == id }
}
func problems(forGym gymId: UUID) -> [Problem] {
return problems.filter { $0.gymId == gymId }
}
func activeProblems(forGym gymId: UUID) -> [Problem] {
return problems.filter { $0.gymId == gymId && $0.isActive }
}
func startSession(gymId: UUID, notes: String? = nil) {
// End any currently active session
if let currentActive = activeSession {
endSession(currentActive.id)
}
let newSession = ClimbSession(gymId: gymId, notes: notes)
activeSession = newSession
sessions.append(newSession)
saveActiveSession()
saveSessions()
DataStateManager.shared.updateDataState()
successMessage = "Session started successfully"
clearMessageAfterDelay()
// MARK: - Start Live Activity for new session
if let gym = gym(withId: gymId) {
Task {
await LiveActivityManager.shared.startLiveActivity(
for: newSession, gymName: gym.name)
}
}
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
func endSession(_ sessionId: UUID) {
if let session = sessions.first(where: { $0.id == sessionId && $0.status == .active }),
let index = sessions.firstIndex(where: { $0.id == sessionId })
{
let completedSession = session.completed()
sessions[index] = completedSession
if activeSession?.id == sessionId {
activeSession = nil
}
saveActiveSession()
saveSessions()
DataStateManager.shared.updateDataState()
successMessage = "Session completed successfully"
clearMessageAfterDelay()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
// MARK: - End Live Activity after session ends
Task {
await LiveActivityManager.shared.endLiveActivity()
}
}
}
func updateSession(_ session: ClimbSession) {
if let index = sessions.firstIndex(where: { $0.id == session.id }) {
sessions[index] = session
if activeSession?.id == session.id {
activeSession = session
saveActiveSession()
}
saveSessions()
DataStateManager.shared.updateDataState()
successMessage = "Session updated successfully"
clearMessageAfterDelay()
// Update Live Activity when session is updated
updateLiveActivityForActiveSession()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
}
func deleteSession(_ session: ClimbSession) {
// Delete associated attempts first
attempts.removeAll { $0.sessionId == session.id }
saveAttempts()
// If this is the active session, clear it
if activeSession?.id == session.id {
activeSession = nil
saveActiveSession()
}
// Delete the session
sessions.removeAll { $0.id == session.id }
saveSessions()
DataStateManager.shared.updateDataState()
successMessage = "Session deleted successfully"
clearMessageAfterDelay()
// Update Live Activity when session is deleted
updateLiveActivityForActiveSession()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
func session(withId id: UUID) -> ClimbSession? {
return sessions.first { $0.id == id }
}
func sessions(forGym gymId: UUID) -> [ClimbSession] {
return sessions.filter { $0.gymId == gymId }
}
func getLastUsedGym() -> Gym? {
let recentSessions = sessions.sorted { $0.date > $1.date }
guard let lastSession = recentSessions.first else { return nil }
return gym(withId: lastSession.gymId)
}
func addAttempt(_ attempt: Attempt) {
attempts.append(attempt)
saveAttempts()
DataStateManager.shared.updateDataState()
successMessage = "Attempt logged successfully"
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
clearMessageAfterDelay()
// Update Live Activity when new attempt is added
updateLiveActivityForActiveSession()
}
func updateAttempt(_ attempt: Attempt) {
if let index = attempts.firstIndex(where: { $0.id == attempt.id }) {
attempts[index] = attempt
saveAttempts()
DataStateManager.shared.updateDataState()
successMessage = "Attempt updated successfully"
clearMessageAfterDelay()
// Update Live Activity when attempt is updated
updateLiveActivityForActiveSession()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
}
func deleteAttempt(_ attempt: Attempt) {
attempts.removeAll { $0.id == attempt.id }
saveAttempts()
DataStateManager.shared.updateDataState()
successMessage = "Attempt deleted successfully"
clearMessageAfterDelay()
// Update Live Activity when attempt is deleted
updateLiveActivityForActiveSession()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
func attempts(forSession sessionId: UUID) -> [Attempt] {
return attempts.filter { $0.sessionId == sessionId }.sorted { $0.timestamp < $1.timestamp }
}
func attempts(forProblem problemId: UUID) -> [Attempt] {
return attempts.filter { $0.problemId == problemId }.sorted { $0.timestamp > $1.timestamp }
}
func successfulAttempts(forProblem problemId: UUID) -> [Attempt] {
return attempts.filter { $0.problemId == problemId && $0.result.isSuccessful }
}
func completedSessions() -> [ClimbSession] {
return sessions.filter { $0.status == .completed }
}
func totalAttempts() -> Int {
return attempts.count
}
func successfulAttempts() -> Int {
return attempts.filter { $0.result.isSuccessful }.count
}
func completedProblems() -> Int {
let completedProblemIds = Set(
attempts.filter { $0.result.isSuccessful }.map { $0.problemId })
return completedProblemIds.count
}
func favoriteGym() -> Gym? {
let gymSessionCounts = Dictionary(grouping: sessions, by: { $0.gymId })
.mapValues { $0.count }
guard let mostUsedGymId = gymSessionCounts.max(by: { $0.value < $1.value })?.key else {
return nil
}
return gym(withId: mostUsedGymId)
}
func resetAllData(showSuccessMessage: Bool = true) {
gyms.removeAll()
problems.removeAll()
sessions.removeAll()
attempts.removeAll()
activeSession = nil
userDefaults.removeObject(forKey: Keys.gyms)
userDefaults.removeObject(forKey: Keys.problems)
userDefaults.removeObject(forKey: Keys.sessions)
userDefaults.removeObject(forKey: Keys.attempts)
userDefaults.removeObject(forKey: Keys.activeSession)
DataStateManager.shared.reset()
if showSuccessMessage {
successMessage = "All data has been reset"
clearMessageAfterDelay()
}
}
func exportData() -> Data? {
do {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
let exportData = ClimbDataBackup(
exportedAt: dateFormatter.string(from: Date()),
version: "2.0",
formatVersion: "2.0",
gyms: gyms.map { BackupGym(from: $0) },
problems: problems.map { BackupProblem(from: $0) },
sessions: sessions.map { BackupClimbSession(from: $0) },
attempts: attempts.map { BackupAttempt(from: $0) }
)
// Collect referenced image paths
let referencedImagePaths = collectReferencedImagePaths()
print("Starting export with \(referencedImagePaths.count) images")
let zipData = try ZipUtils.createExportZip(
exportData: exportData,
referencedImagePaths: referencedImagePaths
)
print("Export completed successfully")
successMessage = "Export completed with \(referencedImagePaths.count) images"
clearMessageAfterDelay()
return zipData
} catch {
let errorMessage = "Export failed: \(error.localizedDescription)"
print("ERROR: \(errorMessage)")
setError(errorMessage)
return nil
}
}
func importData(from data: Data, showSuccessMessage: Bool = true) throws {
do {
let importResult = try ZipUtils.extractImportZip(data: data)
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let dateString = try container.decode(String.self)
if let date = ISO8601DateFormatter().date(from: dateString) {
return date
}
if let date = dateFormatter.date(from: dateString) {
return date
}
return Date()
}
print("Raw JSON content preview:")
print(String(decoding: importResult.jsonData.prefix(500), as: UTF8.self) + "...")
let importData = try decoder.decode(ClimbDataBackup.self, from: importResult.jsonData)
print("Successfully decoded import data:")
print("- Gyms: \(importData.gyms.count)")
print("- Problems: \(importData.problems.count)")
print("- Sessions: \(importData.sessions.count)")
print("- Attempts: \(importData.attempts.count)")
try validateImportData(importData)
resetAllData(showSuccessMessage: showSuccessMessage)
let updatedProblems = updateProblemImagePaths(
problems: importData.problems,
imagePathMapping: importResult.imagePathMapping
)
self.gyms = try importData.gyms.map { try $0.toGym() }
self.problems = try updatedProblems.map { try $0.toProblem() }
self.sessions = try importData.sessions.map { try $0.toClimbSession() }
self.attempts = try importData.attempts.map { try $0.toAttempt() }
saveGyms()
saveProblems()
saveSessions()
saveAttempts()
// Update data state to current time since we just imported new data
DataStateManager.shared.updateDataState()
if showSuccessMessage {
successMessage =
"Data imported successfully with \(importResult.imagePathMapping.count) images"
clearMessageAfterDelay()
}
} catch {
setError("Import failed: \(error.localizedDescription)")
throw error
}
}
func clearMessages() {
errorMessage = nil
successMessage = nil
}
private func clearMessageAfterDelay() {
Task {
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
successMessage = nil
errorMessage = nil
}
}
func setError(_ message: String) {
errorMessage = message
clearMessageAfterDelay()
}
}
extension ClimbingDataManager {
private func collectReferencedImagePaths() -> Set<String> {
var imagePaths = Set<String>()
print("Starting image path collection...")
print("Total problems: \(problems.count)")
for problem in problems {
if !problem.imagePaths.isEmpty {
print(
"Problem '\(problem.name ?? "Unnamed")' has \(problem.imagePaths.count) images"
)
for imagePath in problem.imagePaths {
print(" - Relative path: \(imagePath)")
let fullPath = ImageManager.shared.getFullPath(from: imagePath)
print(" - Full path: \(fullPath)")
// Check if file exists
if FileManager.default.fileExists(atPath: fullPath) {
print(" File exists")
imagePaths.insert(fullPath)
} else {
print(" File does NOT exist")
// Still add it to let ZipUtils handle the error logging
imagePaths.insert(fullPath)
}
}
}
}
print("Collected \(imagePaths.count) total image paths for export")
return imagePaths
}
private func updateProblemImagePaths(
problems: [BackupProblem],
imagePathMapping: [String: String]
) -> [BackupProblem] {
return problems.map { problem in
let updatedImagePaths = (problem.imagePaths ?? []).compactMap { oldPath in
let fileName = URL(fileURLWithPath: oldPath).lastPathComponent
return imagePathMapping[fileName]
}
return problem.withUpdatedImagePaths(updatedImagePaths)
}
}
private func migrateImagePaths() {
var needsUpdate = false
let updatedProblems = problems.map { problem in
let migratedPaths = problem.imagePaths.compactMap { path in
// If it's already a relative path, keep it
if !path.hasPrefix("/") {
return path
}
// For absolute paths, try to migrate to relative
let fileName = URL(fileURLWithPath: path).lastPathComponent
if ImageManager.shared.imageExists(atPath: fileName) {
needsUpdate = true
return fileName
}
// If image doesn't exist, remove from paths
needsUpdate = true
return nil
}
if migratedPaths != problem.imagePaths {
return problem.updated(imagePaths: migratedPaths)
}
return problem
}
if needsUpdate {
problems = updatedProblems
saveProblems()
print("Migrated image paths for \(problems.count) problems")
}
}
private func performImageMaintenance() async {
// Run maintenance in background
await Task.detached {
await ImageManager.shared.performMaintenance()
// Log storage information for debugging
let info = await ImageManager.shared.getStorageInfo()
print(
"Image Storage: \(info.primaryCount) primary, \(info.backupCount) backup, \(info.totalSize / 1024)KB total"
)
}.value
}
func manualImageMaintenance() {
Task {
await performImageMaintenance()
}
}
func getImageStorageInfo() -> String {
let info = ImageManager.shared.getStorageInfo()
return """
Image Storage Status:
• Primary: \(info.primaryCount) files
• Backup: \(info.backupCount) files
• Total Size: \(formatBytes(info.totalSize))
"""
}
func cleanupUnusedImages() {
// Get all image paths currently referenced in problems
let referencedImages = Set(
problems.flatMap { $0.imagePaths.map { ImageManager.shared.getRelativePath(from: $0) } }
)
// Get all files in storage
if let primaryFiles = try? FileManager.default.contentsOfDirectory(
atPath: ImageManager.shared.getImagesDirectoryPath())
{
let orphanedFiles = primaryFiles.filter { !referencedImages.contains($0) }
for fileName in orphanedFiles {
_ = ImageManager.shared.deleteImage(atPath: fileName)
}
if !orphanedFiles.isEmpty {
print("Cleaned up \(orphanedFiles.count) orphaned image files")
}
}
}
private func formatBytes(_ bytes: Int64) -> String {
let kb = Double(bytes) / 1024.0
let mb = kb / 1024.0
if mb >= 1.0 {
return String(format: "%.1f MB", mb)
} else {
return String(format: "%.0f KB", kb)
}
}
func forceImageRecovery() {
print("User initiated force image recovery")
ImageManager.shared.forceRecoveryMigration()
// Refresh the UI after recovery
objectWillChange.send()
}
func emergencyImageRestore() {
print("User initiated emergency image restore")
ImageManager.shared.emergencyImageRestore()
// Refresh the UI after restore
objectWillChange.send()
}
func validateImageStorage() -> Bool {
return ImageManager.shared.validateStorageIntegrity()
}
func getImageRecoveryStatus() -> String {
let isValid = validateImageStorage()
let info = ImageManager.shared.getStorageInfo()
return """
Image Storage Health: \(isValid ? "Good" : "Needs Recovery")
Primary Files: \(info.primaryCount)
Backup Files: \(info.backupCount)
Total Size: \(formatBytes(info.totalSize))
\(isValid ? "No action needed" : "Consider running Force Recovery")
"""
}
func testLiveActivity() {
print("🧪 Testing Live Activity functionality...")
// Check Live Activity availability
let status = LiveActivityManager.shared.checkLiveActivityAvailability()
print(status)
// Test with dummy data if we have a gym
guard let testGym = gyms.first else {
print("ERROR: No gyms available for testing")
return
}
// Create a test session
let testSession = ClimbSession(gymId: testGym.id, notes: "Test session for Live Activity")
Task {
await LiveActivityManager.shared.startLiveActivity(
for: testSession, gymName: testGym.name)
// Wait a bit then update
try? await Task.sleep(nanoseconds: 2_000_000_000)
await LiveActivityManager.shared.updateLiveActivity(
elapsed: 120, totalAttempts: 5, completedProblems: 1)
// Wait then end
try? await Task.sleep(nanoseconds: 5_000_000_000)
await LiveActivityManager.shared.endLiveActivity()
}
}
private func checkAndRestartLiveActivity() async {
guard let activeSession = activeSession else {
// No active session, make sure all Live Activities are cleaned up
await LiveActivityManager.shared.endLiveActivity()
return
}
// Only restart if session is actually active
guard activeSession.status == .active else {
print(
"WARNING: Session exists but is not active (status: \(activeSession.status)), ending Live Activity"
)
await LiveActivityManager.shared.endLiveActivity()
return
}
if let gym = gym(withId: activeSession.gymId) {
print("Checking Live Activity for active session at \(gym.name)")
// First cleanup any dismissed activities
await LiveActivityManager.shared.cleanupDismissedActivities()
// Then attempt to restart if needed
await LiveActivityManager.shared.restartLiveActivityIfNeeded(
activeSession: activeSession,
gymName: gym.name
)
}
}
/// Call this when app becomes active to check for Live Activity restart
func onAppBecomeActive() {
print("App became active - checking Live Activity status")
Task {
await checkAndRestartLiveActivity()
}
}
/// Call this when app enters background to update Live Activity
func onAppEnterBackground() {
print("App entering background - updating Live Activity if needed")
Task {
await updateLiveActivityData()
}
}
/// Setup notifications for Live Activity events
private func setupLiveActivityNotifications() {
liveActivityObserver = NotificationCenter.default.addObserver(
forName: .liveActivityDismissed,
object: nil,
queue: .main
) { [weak self] _ in
print("🔔 Received Live Activity dismissed notification - attempting restart")
Task { @MainActor in
await self?.handleLiveActivityDismissed()
}
}
}
/// Handle Live Activity being dismissed by user
private func handleLiveActivityDismissed() async {
guard let activeSession = activeSession,
activeSession.status == .active,
let gym = gym(withId: activeSession.gymId)
else {
return
}
print("Attempting to restart dismissed Live Activity for \(gym.name)")
// Wait a bit before restarting to avoid frequency limits
try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
await LiveActivityManager.shared.startLiveActivity(
for: activeSession,
gymName: gym.name
)
// Update with current data
await updateLiveActivityData()
}
/// Update Live Activity with current session statistics
private func updateLiveActivityData() async {
guard let activeSession = activeSession,
activeSession.status == .active
else { return }
let elapsed = Date().timeIntervalSince(activeSession.startTime ?? activeSession.date)
let sessionAttempts = attempts.filter { $0.sessionId == activeSession.id }
let totalAttempts = sessionAttempts.count
let completedProblems = Set(
sessionAttempts.filter { $0.result.isSuccessful }.map { $0.problemId }
).count
await LiveActivityManager.shared.updateLiveActivity(
elapsed: elapsed,
totalAttempts: totalAttempts,
completedProblems: completedProblems
)
}
/// Update Live Activity with current session data
private func updateLiveActivityForActiveSession() {
guard let activeSession = activeSession,
activeSession.status == .active,
let gym = gym(withId: activeSession.gymId)
else {
print("WARNING: Live Activity update skipped - no active session or gym")
if let session = activeSession {
print(" Session ID: \(session.id)")
print(" Session Status: \(session.status)")
print(" Gym ID: \(session.gymId)")
}
return
}
let attemptsForSession = attempts(forSession: activeSession.id)
let totalAttempts = attemptsForSession.count
let completedProblemIds = Set(
attemptsForSession.filter { $0.result.isSuccessful }.map { $0.problemId }
)
let completedProblems = completedProblemIds.count
let elapsedInterval: TimeInterval
if let startTime = activeSession.startTime {
elapsedInterval = Date().timeIntervalSince(startTime)
} else {
elapsedInterval = 0
}
print("Live Activity Update Debug:")
print(" Session ID: \(activeSession.id)")
print(" Gym: \(gym.name)")
print(" Total attempts in session: \(totalAttempts)")
print(" Completed problems: \(completedProblems)")
print(" Elapsed time: \(elapsedInterval) seconds")
print(
" All attempts for session: \(attemptsForSession.map { "\($0.result) - Problem: \($0.problemId)" })"
)
Task {
await LiveActivityManager.shared.updateLiveActivity(
elapsed: elapsedInterval,
totalAttempts: totalAttempts,
completedProblems: completedProblems
)
}
}
/// Manually force Live Activity update (useful for debugging)
func forceLiveActivityUpdate() {
updateLiveActivityForActiveSession()
}
/// Update widget timeline when data changes
private func updateWidgetTimeline() {
#if canImport(WidgetKit)
WidgetCenter.shared.reloadTimelines(ofKind: "SessionStatusLive")
#endif
}
/// Debug function to manually trigger widget data update
func debugUpdateWidgetData() {
// Force save all data to widget
saveGyms()
saveSessions()
saveAttempts()
}
private func validateImportData(_ importData: ClimbDataBackup) throws {
if importData.gyms.isEmpty {
throw NSError(
domain: "ImportError", code: 1,
userInfo: [NSLocalizedDescriptionKey: "Import data is invalid: no gyms found"])
}
}
}
extension ClimbingDataManager {
static var preview: ClimbingDataManager {
let manager = ClimbingDataManager()
let sampleGym = Gym(
name: "Sample Climbing Gym",
location: "123 Rock St, Boulder, CO",
supportedClimbTypes: [.boulder, .rope],
difficultySystems: [.vScale, .yds]
)
manager.gyms = [sampleGym]
let sampleProblem = Problem(
gymId: sampleGym.id,
name: "Crimpy Overhang",
description: "Technical overhang with small holds",
climbType: .boulder,
difficulty: DifficultyGrade(system: .vScale, grade: "V4"),
tags: ["technical", "overhang"],
location: "Cave area"
)
manager.problems = [sampleProblem]
return manager
}
}